diff --git a/Package.swift b/Package.swift index 32f7cbfe2..c5b9be1ae 100644 --- a/Package.swift +++ b/Package.swift @@ -93,7 +93,7 @@ let package = Package( .testTarget( name: "WebURLTests", dependencies: ["WebURL", "WebURLTestSupport", "Checkit"], - exclude: ["KeyValuePairsTests.swift"] + exclude: ["KeyValuePairs"] ), .testTarget( name: "WebURLDeprecatedAPITests", diff --git a/Package@swift-5.5.swift b/Package@swift-5.5.swift index 8bcabe0a3..5edf53cc7 100644 --- a/Package@swift-5.5.swift +++ b/Package@swift-5.5.swift @@ -93,7 +93,7 @@ let package = Package( .testTarget( name: "WebURLTests", dependencies: ["WebURL", "WebURLTestSupport", "Checkit"], - exclude: ["KeyValuePairsTests.swift"] + exclude: ["KeyValuePairs"] ), .testTarget( name: "WebURLDeprecatedAPITests", diff --git a/Sources/WebURL/WebURL+KeyValuePairs.swift b/Sources/WebURL/WebURL+KeyValuePairs.swift index b6259e646..1535ecf0f 100644 --- a/Sources/WebURL/WebURL+KeyValuePairs.swift +++ b/Sources/WebURL/WebURL+KeyValuePairs.swift @@ -627,6 +627,36 @@ extension KeyValueStringSchema { } } +internal enum KeyValueStringSchemaVerificationFailure: Error, CustomStringConvertible { + + case preferredKeyValueDelimiterIsInvalid + case preferredKeyValueDelimiterNotRecognized + case preferredPairDelimiterIsInvalid + case preferredPairDelimiterNotRecognized + case invalidKeyValueDelimiterIsRecognized + case invalidPairDelimiterIsRecognized + case inconsistentSpaceEncoding + + public var description: String { + switch self { + case .preferredKeyValueDelimiterIsInvalid: + return "Schema's preferred key-value delimiter is invalid" + case .preferredKeyValueDelimiterNotRecognized: + return "Schema does not recognize its preferred key-value delimiter as a key-value delimiter" + case .preferredPairDelimiterIsInvalid: + return "Schema's preferred pair delimiter is invalid" + case .preferredPairDelimiterNotRecognized: + return "Schema does not recognize its preferred pair delimiter as a pair delimiter" + case .invalidKeyValueDelimiterIsRecognized: + return "isKeyValueDelimiter recognizes an invalid delimiter" + case .invalidPairDelimiterIsRecognized: + return "isPairDelimiter recognizes an invalid delimiter" + case .inconsistentSpaceEncoding: + return "encodeSpaceAsPlus is true, so decodePlusAsSpace must also be true" + } + } +} + extension KeyValueStringSchema { /// Checks this schema for consistency. @@ -658,19 +688,47 @@ extension KeyValueStringSchema { /// > For those that do, it is recommended to run this verification /// > as part of your regular unit tests. /// - public func verify(for component: KeyValuePairsSupportedComponent) { + public func verify(for component: KeyValuePairsSupportedComponent) throws { + + // Preferred delimiters must not require escaping. - let delimiters = verifyDelimitersDoNotNeedEscaping(in: component) + let preferredDelimiters = verifyDelimitersDoNotNeedEscaping(in: component) - if !isKeyValueDelimiter(delimiters.keyValue) { - fatalError("Inconsistent schema: preferred key-value delimiter is not recognized as a key-value delimiter") + if preferredDelimiters.keyValue == .max { + throw KeyValueStringSchemaVerificationFailure.preferredKeyValueDelimiterIsInvalid } - if !isPairDelimiter(delimiters.pair) { - fatalError("Inconsistent schema: preferred pair delimiter is not recognized as a pair delimiter") + if preferredDelimiters.pair == .max { + throw KeyValueStringSchemaVerificationFailure.preferredPairDelimiterIsInvalid } + + // isKeyValueDelimiter/isPairDelimiter must recognize preferred delimiters, + // and must not recognize other reserved characters (e.g. %, ASCII hex digits, +). + + if !isKeyValueDelimiter(preferredDelimiters.keyValue) { + throw KeyValueStringSchemaVerificationFailure.preferredKeyValueDelimiterNotRecognized + } + if !isPairDelimiter(preferredDelimiters.pair) { + throw KeyValueStringSchemaVerificationFailure.preferredPairDelimiterNotRecognized + } + + func delimiterPredicateIsInvalid(_ isDelimiter: (UInt8) -> Bool) -> Bool { + "0123456789abcdefABCDEF%+".utf8.contains(where: isDelimiter) + } + + if delimiterPredicateIsInvalid(isKeyValueDelimiter) { + throw KeyValueStringSchemaVerificationFailure.invalidKeyValueDelimiterIsRecognized + } + if delimiterPredicateIsInvalid(isPairDelimiter) { + throw KeyValueStringSchemaVerificationFailure.invalidPairDelimiterIsRecognized + } + + // Space encoding must be consistent. + if encodeSpaceAsPlus, !decodePlusAsSpace { - fatalError("Inconsistent schema: encodeSpaceAsPlus is true, so decodePlusAsSpace must also be true") + throw KeyValueStringSchemaVerificationFailure.inconsistentSpaceEncoding } + + // All checks passed. } } @@ -1064,7 +1122,7 @@ extension WebURL.KeyValuePairs: CustomStringConvertible { // -------------------------------------------- -// MARK: - Reading: Collection +// MARK: - Reading: By Location. // -------------------------------------------- @@ -1218,7 +1276,7 @@ extension WebURL.KeyValuePairs: Collection { } -// MARK: - TODO: BidirectionalCollection. +// MARK: TODO: BidirectionalCollection. extension WebURL.KeyValuePairs { @@ -2522,41 +2580,40 @@ extension KeyValueStringSchema { // - Must not be the percent sign (`%`), plus sign (`+`), space, or a hex digit, and // - Must not require escaping in the URL component(s) used with this schema. - precondition( - ASCII(keyValueDelimiter)?.isHexDigit == false - && keyValueDelimiter != ASCII.percentSign.codePoint - && keyValueDelimiter != ASCII.plus.codePoint, - "Schema's preferred key-value delimiter is invalid" - ) - precondition( - ASCII(pairDelimiter)?.isHexDigit == false - && pairDelimiter != ASCII.percentSign.codePoint - && pairDelimiter != ASCII.plus.codePoint, - "Schema's preferred pair delimiter is invalid" - ) + guard + ASCII(keyValueDelimiter)?.isHexDigit == false, + keyValueDelimiter != ASCII.percentSign.codePoint, + keyValueDelimiter != ASCII.plus.codePoint + else { + return (.max, 0) + } + guard + ASCII(pairDelimiter)?.isHexDigit == false, + pairDelimiter != ASCII.percentSign.codePoint, + pairDelimiter != ASCII.plus.codePoint + else { + return (0, .max) + } switch component.value { case .query: let encodeSet = URLEncodeSet.SpecialQuery() - precondition( - !encodeSet.shouldPercentEncode(ascii: keyValueDelimiter), - "Schema's preferred key-value delimiter may not be used in the query" - ) - precondition( - !encodeSet.shouldPercentEncode(ascii: pairDelimiter), - "Schema's preferred pair delimiter may not be used in the query" - ) + guard !encodeSet.shouldPercentEncode(ascii: keyValueDelimiter) else { + return (.max, 0) + } + guard !encodeSet.shouldPercentEncode(ascii: pairDelimiter) else { + return (0, .max) + } case .fragment: let encodeSet = URLEncodeSet.Fragment() - precondition( - !encodeSet.shouldPercentEncode(ascii: keyValueDelimiter), - "Schema's preferred key-value delimiter may not be used in the fragment" - ) - precondition( - !encodeSet.shouldPercentEncode(ascii: pairDelimiter), - "Schema's preferred pair delimiter may not be used in the fragment" - ) + guard !encodeSet.shouldPercentEncode(ascii: keyValueDelimiter) else { + return (.max, 0) + } + guard !encodeSet.shouldPercentEncode(ascii: pairDelimiter) else { + return (0, .max) + } } + return (keyValueDelimiter, pairDelimiter) } @@ -2587,6 +2644,17 @@ extension KeyValuePairsSupportedComponent { } } +@inlinable +internal func _trapOnInvalidDelimiters( + _ delimiters: (keyValue: UInt8, pair: UInt8) +) -> (keyValue: UInt8, pair: UInt8) { + precondition( + delimiters.keyValue != .max && delimiters.pair != .max, + "Schema has invalid delimiters" + ) + return delimiters +} + extension URLStorage { /// Replaces the given range of key-value pairs with a new collection of key-value pairs. @@ -2642,7 +2710,9 @@ extension URLStorage { return .success(newUpper...Element) { + self.init(key: kvp.key, value: kvp.value) + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.key.utf8.elementsEqual(rhs.key.utf8) && lhs.value.utf8.elementsEqual(rhs.value.utf8) + } +} + +/// Asserts that the given `WebURL.KeyValuePairs` (or slice thereof) +/// contains the same pairs as the given list. +/// +/// Keys and values from the `WebURL.KeyValuePairs` are decoded before checking +/// for equality. The lists must match at the code-unit/code-point level. +/// +func XCTAssertEqualKeyValuePairs( + _ left: some Collection.Element>, + _ right: some Collection, + file: StaticString = #fileID, + line: UInt = #line +) { + XCTAssertEqualElements(left.map { KeyValuePair($0) }, right, file: file, line: line) +} + +/// Asserts that the given `WebURL.KeyValuePairs` (or slice thereof) +/// contains the same pairs as the given Array. +/// +/// Keys and values from the `WebURL.KeyValuePairs` are decoded before checking +/// for equality. The lists must match at the code-unit/code-point level. +/// +func XCTAssertEqualKeyValuePairs( + _ left: some Collection.Element>, + _ right: [(key: String, value: String)], + file: StaticString = #fileID, + line: UInt = #line +) { + XCTAssertEqualKeyValuePairs(left, right.map { KeyValuePair(key: $0.key, value: $0.value) }, file: file, line: line) +} + +/// A key-value string schema with non-standard delimiters. +/// +/// ``` +/// key1:value1,key2:value2 +/// ``` +/// +/// Other than delimiters, it should match `PercentEncodedKeyValueString`. +/// +struct CommaSeparated: KeyValueStringSchema { + + var preferredPairDelimiter: UInt8 { UInt8(ascii: ",") } + var preferredKeyValueDelimiter: UInt8 { UInt8(ascii: ":") } + + var decodePlusAsSpace: Bool { false } + + func shouldPercentEncode(ascii codePoint: UInt8) -> Bool { + PercentEncodedKeyValueString().shouldPercentEncode(ascii: codePoint) + } +} + +/// A key-value string schema which supports additional options for form-encoding. +/// +struct ExtendedForm: KeyValueStringSchema { + + var semicolonIsPairDelimiter = false + var encodeSpaceAsPlus = false + + func isPairDelimiter(_ codePoint: UInt8) -> Bool { + codePoint == UInt8(ascii: "&") || (semicolonIsPairDelimiter && codePoint == UInt8(ascii: ";")) + } + + var preferredPairDelimiter: UInt8 { UInt8(ascii: "&") } + var preferredKeyValueDelimiter: UInt8 { UInt8(ascii: "=") } + + var decodePlusAsSpace: Bool { true } + + func shouldPercentEncode(ascii codePoint: UInt8) -> Bool { + FormCompatibleKeyValueString().shouldPercentEncode(ascii: codePoint) + } +} + +/// Asserts that information in the `WebURL.KeyValuePairs` cache +/// is consistent with a freshly-recalculated cache. +/// +func XCTAssertKeyValuePairCacheIsAccurate(_ kvps: WebURL.KeyValuePairs) { + + let expectedCache = type(of: kvps).Cache.calculate( + storage: kvps.storage, + component: kvps.component, + schema: kvps.schema + ) + XCTAssertEqual(kvps.cache.startIndex, expectedCache.startIndex) + XCTAssertEqual(kvps.cache.componentContents, expectedCache.componentContents) +} diff --git a/Tests/WebURLTests/KeyValuePairsTests.swift b/Tests/WebURLTests/KeyValuePairs/KeyValuePairsTests.swift similarity index 52% rename from Tests/WebURLTests/KeyValuePairsTests.swift rename to Tests/WebURLTests/KeyValuePairs/KeyValuePairsTests.swift index 71c059943..d521b0de9 100644 --- a/Tests/WebURLTests/KeyValuePairsTests.swift +++ b/Tests/WebURLTests/KeyValuePairs/KeyValuePairsTests.swift @@ -12,209 +12,78 @@ // See the License for the specific language governing permissions and // limitations under the License. +#if swift(<5.7) + #error("WebURL.KeyValuePairs requires Swift 5.7 or newer") +#endif + import XCTest import Checkit @testable import WebURL -struct CommaSeparated: KeyValueStringSchema { - - var minimalPercentEncoding = false - - var preferredPairDelimiter: UInt8 { UInt8(ascii: ",") } - var preferredKeyValueDelimiter: UInt8 { UInt8(ascii: ":") } - - var decodePlusAsSpace: Bool { false } - var encodeSpaceAsPlus: Bool { false } - - func shouldPercentEncode(ascii codePoint: UInt8) -> Bool { - if minimalPercentEncoding { - return PercentEncodedKeyValueString().shouldPercentEncode(ascii: codePoint) - } else { - return FormCompatibleKeyValueString().shouldPercentEncode(ascii: codePoint) - } - } -} - -struct ExtendedForm: KeyValueStringSchema { - - var semicolonIsPairDelimiter = false - var encodeSpaceAsPlus = false - - func isPairDelimiter(_ codePoint: UInt8) -> Bool { - codePoint == UInt8(ascii: "&") || (semicolonIsPairDelimiter && codePoint == UInt8(ascii: ";")) - } - - var preferredPairDelimiter: UInt8 { UInt8(ascii: "&") } - var preferredKeyValueDelimiter: UInt8 { UInt8(ascii: "=") } - - var decodePlusAsSpace: Bool { true } - - func shouldPercentEncode(ascii codePoint: UInt8) -> Bool { - FormCompatibleKeyValueString().shouldPercentEncode(ascii: codePoint) - } -} - -/// A key-value pair. -/// -/// Note that the `Equatable` conformance requires exact unicode scalar equality. -/// -fileprivate struct KeyValuePair: Equatable { - - var key: String - var value: String - - init(key: String, value: String) { - self.key = key - self.value = value - } - - init(_ kvp: WebURL.KeyValuePairs.Element) { - self.init(key: kvp.key, value: kvp.value) - } - - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.key.utf8.elementsEqual(rhs.key.utf8) && lhs.value.utf8.elementsEqual(rhs.value.utf8) - } -} - -extension KeyValuePair { - - init(_ keyValuePair: (String, String)) { - self.init(key: keyValuePair.0, value: keyValuePair.1) - } -} - - - -fileprivate func XCTAssertEqualKeyValuePairs(_ left: [KeyValuePair], _ right: [(String, String)]) { - XCTAssertEqual(left, right.map { KeyValuePair(key: $0.0, value: $0.1) }) -} - -fileprivate func XCTAssertEqualKeyValuePairs( - _ left: WebURL.KeyValuePairs, _ right: [(key: String, value: String)] -) { - XCTAssertEqualElements( - left.map { KeyValuePair($0) }, - right.map { KeyValuePair(key: $0.key, value: $0.value) } - ) -} - -fileprivate func XCTAssertEqualKeyValuePairs( - _ left: WebURL.KeyValuePairs, _ right: [(String, String)] -) { - XCTAssertEqualElements( - left.map { KeyValuePair($0) }, - right.map { KeyValuePair(key: $0.0, value: $0.1) } - ) -} - -fileprivate func XCTAssertEqualKeyValuePairs( - _ left: WebURL.KeyValuePairs.SubSequence, _ right: [(key: String, value: String)] -) { - XCTAssertEqualElements( - left.map { KeyValuePair($0) }, - right.map { KeyValuePair(key: $0.key, value: $0.value) } - ) -} - -fileprivate func XCTAssertEqualKeyValuePairs( - _ left: WebURL.KeyValuePairs, _ right: [KeyValuePair] -) { - XCTAssertEqualElements(left.lazy.map { KeyValuePair($0) }, right) -} - -fileprivate func XCTAssertEqualKeyValuePairs( - _ left: WebURL.KeyValuePairs.SubSequence, _ right: [KeyValuePair] -) { - XCTAssertEqualElements(left.lazy.map { KeyValuePair($0) }, right) -} - -fileprivate func XCTAssertKeyValuePairCacheIsAccurate(_ kvps: WebURL.KeyValuePairs) { - - let expectedCache = WebURL.KeyValuePairs.Cache.calculate( - storage: kvps.storage, - component: kvps.component, - schema: kvps.schema - ) - XCTAssertEqual(kvps.cache.startIndex, expectedCache.startIndex) - XCTAssertEqual(kvps.cache.componentContents, expectedCache.componentContents) -} - final class KeyValuePairsTests: XCTestCase {} - - extension KeyValuePairsTests { static let SpecialCharacters = "\u{0000}\u{0001}\u{0009}\u{000A}\u{000D} !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" static let SpecialCharacters_Escaped_Form = "%00%01%09%0A%0D%20%21%22%23%24%25%26%27%28%29*%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D%7E" static let SpecialCharacters_Escaped_Form_Plus = "%00%01%09%0A%0D+%21%22%23%24%25%26%27%28%29*%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D%7E" - static let SpecialCharacters_Escaped_MinPctEnc_Query = #"%00%01%09%0A%0D%20!%22%23$%25%26%27()*%2B,-./:;%3C%3D%3E?@[\]^_`{|}~"# - static let SpecialCharacters_Escaped_MinCommaSep_Frag = #"%00%01%09%0A%0D%20!%22#$%25&'()*%2B%2C-./%3A;%3C=%3E?@[\]^_%60{|}~"# - - // For PercentEncodedKeyValueString.shouldPercentEncode => true for isNonURLCodePoint - // static let SpecialCharacters_Escaped_MinPctEnc_Query = #"%00%01%09%0A%0D%20!%22%23$%25%26%27()*%2B,-./:;%3C%3D%3E?@%5B%5C%5D%5E_%60%7B%7C%7D~"# - // static let SpecialCharacters_Escaped_MinCommaSep_Frag = #"%00%01%09%0A%0D%20!%22%23$%25&'()*%2B%2C-./%3A;%3C=%3E?@%5B%5C%5D%5E_%60%7B%7C%7D~"# -} - -extension KeyValuePairsTests { - - func testSchemaVerification() { - - // Note: We can't test schemas with infractions since they 'fatalError'. - // Perhaps we should change that? - // That means adding another enum as a result type, Error, CustomStringConvertible conformances. - // Can we limit that to debug builds of clients? - // Would really rather not pay the code-size for something like that. - - func _doSchemaVerification( - _ schema: Schema, for component: KeyValuePairsSupportedComponent - ) where Schema: KeyValueStringSchema { - schema.verify(for: component) - } + static let SpecialCharacters_Escaped_PrctEnc_Query = #"%00%01%09%0A%0D%20!%22%23$%25%26%27()*%2B,-./:;%3C%3D%3E?@[\]^_`{|}~"# + static let SpecialCharacters_Escaped_CommaSep_Frag = #"%00%01%09%0A%0D%20!%22#$%25&'()*%2B%2C-./%3A;%3C=%3E?@[\]^_%60{|}~"# - _doSchemaVerification(.formEncoded, for: .query) - _doSchemaVerification(.formEncoded, for: .fragment) - _doSchemaVerification(.percentEncoded, for: .query) - _doSchemaVerification(.percentEncoded, for: .fragment) - - _doSchemaVerification(CommaSeparated(), for: .query) - _doSchemaVerification(CommaSeparated(), for: .fragment) - } + // For PercentEncodedKeyValueString.shouldPercentEncode == true when !isNonURLCodePoint: + // static let SpecialCharacters_Escaped_PrctEnc_Query = #"%00%01%09%0A%0D%20!%22%23$%25%26%27()*%2B,-./:;%3C%3D%3E?@%5B%5C%5D%5E_%60%7B%7C%7D~"# + // static let SpecialCharacters_Escaped_CommaSep_Frag = #"%00%01%09%0A%0D%20!%22%23$%25&'()*%2B%2C-./%3A;%3C=%3E?@%5B%5C%5D%5E_%60%7B%7C%7D~"# } -// ------------------------------- -// MARK: - Reading -// ------------------------------- +// -------------------------------------------- +// MARK: - Reading: By Location +// -------------------------------------------- extension KeyValuePairsTests { + /// Tests the KeyValuePairs conformance to Collection. + /// + /// This includes testing that the view contains the expected elements on multiple passes, + /// and that indexing works as required by the protocol (via `swift-checkit`). + /// + /// Many of the key-value pairs have interesting features, such as special characters, + /// pairs with the same key, pairs whose key differs in Unicode normalization, + /// empty key names or values, etc. + /// + /// Tests are repeated: + /// + /// 1. In various URL components, + /// 2. Using a variety of schemas with different escaping rules and delimiters, and + /// 3. With empty pairs inserted at various points in the URL component. + /// func testCollectionConformance() { - func _testCollectionConformance(_ kvps: WebURL.KeyValuePairs) { - XCTAssertEqualKeyValuePairs(kvps, [ + func _testCollectionConformance(_ kvps: WebURL.KeyValuePairs) { + let expected = [ (key: "a", value: "b"), - (key: "mixed space & plus", value: "d"), + (key: "sp ac & es", value: "d"), (key: "dup", value: "e"), (key: "", value: "foo"), (key: "noval", value: ""), (key: "emoji", value: "👀"), (key: "jalapen\u{0303}os", value: "nfd"), - (key: "specials", value: ##"!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"##), + (key: "specials", value: Self.SpecialCharacters), (key: "dup", value: "f"), (key: "jalape\u{00F1}os", value: "nfc"), - ]) - XCTAssertEqual(kvps.count, 10) + (key: "1+1", value: "2"), + ] + XCTAssertEqualKeyValuePairs(kvps, expected) CollectionChecker.check(kvps) + XCTAssertEqualKeyValuePairs(kvps, expected) } - // Tests are repeated with some empty key-value pairs injected in various places. - // These should be skipped (there is no Index which covers that range of the string), + // Empty key-value pairs should be skipped. There is no Index which covers that range of the string, // so they should be transparent as far as the Collection side of things is concerned. // Form encoding in the query. + do { let injections = [ (start: "", middle: "", end: ""), @@ -224,14 +93,15 @@ extension KeyValuePairsTests { (start: "&&&&", middle: "&&&&", end: "&&&&") ] for (start, middle, end) in injections { - let url = WebURL(#"http://example/?\#(start)a=b&mixed%20space+%26+plus=d&dup=e\#(middle)&=foo&noval&emoji=👀&jalapen\#u{0303}os=nfd&specials=!"%23$%%26'()*%2B,-./:;<%3D>?@[\]^_`{|}~&dup=f&jalape\#u{00F1}os=nfc\#(end)#a=z"#)! - XCTAssertEqual(url.query, #"\#(start)a=b&mixed%20space+%26+plus=d&dup=e\#(middle)&=foo&noval&emoji=%F0%9F%91%80&jalapen%CC%83os=nfd&specials=!%22%23$%%26%27()*%2B,-./:;%3C%3D%3E?@[\]^_`{|}~&dup=f&jalape%C3%B1os=nfc\#(end)"#) + let url = WebURL("http://example/?\(start)a=b&sp%20ac+%26+es=d&dup=e\(middle)&=foo&noval&emoji=👀&jalapen\u{0303}os=nfd&specials=\(Self.SpecialCharacters_Escaped_Form)&dup=f&jalape\u{00F1}os=nfc&1%2B1=2\(end)#a=z")! + XCTAssertEqual(url.query, "\(start)a=b&sp%20ac+%26+es=d&dup=e\(middle)&=foo&noval&emoji=%F0%9F%91%80&jalapen%CC%83os=nfd&specials=\(Self.SpecialCharacters_Escaped_Form)&dup=f&jalape%C3%B1os=nfc&1%2B1=2\(end)") _testCollectionConformance(url.queryParams) } } // Form encoding in the query (2). // Semicolons are also allowed as pair delimiters. + do { let injections = [ (start: "", middle: "", end: ""), @@ -241,14 +111,15 @@ extension KeyValuePairsTests { (start: "&&;&", middle: "&;&&", end: "&&;;") ] for (start, middle, end) in injections { - let url = WebURL(#"http://example/?\#(start)a=b&mixed%20space+%26+plus=d;dup=e\#(middle)&=foo&noval&emoji=👀;jalapen\#u{0303}os=nfd;specials=!"%23$%%26'()*%2B,-./:%3B<%3D>?@[\]^_`{|}~&dup=f&jalape\#u{00F1}os=nfc\#(end)#a=z"#)! - XCTAssertEqual(url.query, #"\#(start)a=b&mixed%20space+%26+plus=d;dup=e\#(middle)&=foo&noval&emoji=%F0%9F%91%80;jalapen%CC%83os=nfd;specials=!%22%23$%%26%27()*%2B,-./:%3B%3C%3D%3E?@[\]^_`{|}~&dup=f&jalape%C3%B1os=nfc\#(end)"#) + let url = WebURL("http://example/?\(start)a=b&sp%20ac+%26+es=d;dup=e\(middle)&=foo&noval&emoji=👀;jalapen\u{0303}os=nfd;specials=\(Self.SpecialCharacters_Escaped_Form)&dup=f&jalape\u{00F1}os=nfc&1%2B1=2\(end)#a=z")! + XCTAssertEqual(url.query, "\(start)a=b&sp%20ac+%26+es=d;dup=e\(middle)&=foo&noval&emoji=%F0%9F%91%80;jalapen%CC%83os=nfd;specials=\(Self.SpecialCharacters_Escaped_Form)&dup=f&jalape%C3%B1os=nfc&1%2B1=2\(end)") _testCollectionConformance(url.keyValuePairs(in: .query, schema: ExtendedForm(semicolonIsPairDelimiter: true))) } } // Custom schema in the fragment. - // Note that the escaping is different (e.g. 'specials' can include unescaped & and =). + // Note the use of unescaped '&' and '+' characters. + do { let injections = [ (start: "", middle: "", end: ""), @@ -258,229 +129,198 @@ extension KeyValuePairsTests { (start: ",,,,", middle: ",,,,", end: ",,,,") ] for (start, middle, end) in injections { - let url = WebURL(##"http://example/?a:z#\##(start)a:b,mixed%20space%20&%20plus:d,dup:e\##(middle),:foo,noval,emoji:👀,jalapen\##u{0303}os:nfd,specials:!"#$%&'()*+%2C-./%3A;<=>?@[\]^_`{|}~,dup:f,jalape\##u{00F1}os:nfc\##(end)"##)! - XCTAssertEqual(url.fragment, ##"\##(start)a:b,mixed%20space%20&%20plus:d,dup:e\##(middle),:foo,noval,emoji:%F0%9F%91%80,jalapen%CC%83os:nfd,specials:!%22#$%&'()*+%2C-./%3A;%3C=%3E?@[\]^_%60{|}~,dup:f,jalape%C3%B1os:nfc\##(end)"##) + let url = WebURL("http://example/?a:z#\(start)a:b,sp%20ac%20&%20es:d,dup:e\(middle),:foo,noval,emoji:👀,jalapen\u{0303}os:nfd,specials:\(Self.SpecialCharacters_Escaped_CommaSep_Frag),dup:f,jalape\u{00F1}os:nfc,1+1:2\(end)")! + XCTAssertEqual(url.fragment, "\(start)a:b,sp%20ac%20&%20es:d,dup:e\(middle),:foo,noval,emoji:%F0%9F%91%80,jalapen%CC%83os:nfd,specials:\(Self.SpecialCharacters_Escaped_CommaSep_Frag),dup:f,jalape%C3%B1os:nfc,1+1:2\(end)") _testCollectionConformance(url.keyValuePairs(in: .fragment, schema: CommaSeparated())) } } } + /// Tests situations where KeyValuePairs is expected to be empty. + /// + /// Also checks that a list of pairs, each of which has an empty key and value, + /// is not the same as an empty list. + /// + /// Tests are repeated: + /// + /// 1. In various URL components, + /// 2. Using a variety of schemas with different escaping rules and delimiters, and + /// func testEmptyCollection() { - func _testEmptyCollection( - _ component: WritableKeyPath, - prefix: String, view getView: (WebURL) -> WebURL.KeyValuePairs + func _testEmptyCollection( + component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema, checkpoints: [String] ) { - var url = WebURL("http://example/")! - - func checkViewIsEmptyList() { - let view = getView(url) - XCTAssertEqual(view.isEmpty, true) - for _ in view { XCTFail("Should be empty") } - CollectionChecker.check(view) - } - // If the URL component is 'nil', we should get an empty list of pairs. - XCTAssertEqual(url.serialized(), "http://example/") - XCTAssertEqual(url[keyPath: component], nil) - checkViewIsEmptyList() - - // If the URL component is the empty string, we should also get an empty list of pairs. - url[keyPath: component] = "" - XCTAssertEqual(url.serialized(), "http://example/\(prefix)") - XCTAssertEqual(url[keyPath: component], "") - checkViewIsEmptyList() - - let schema = getView(url).schema - - // If the URL component only contains a sequence of empty key-value pairs (e.g. "&&&"), - // they should all be skipped, and we should also get an empty list of pairs. - for n in 1...5 { - let pairDelimiter = Character(UnicodeScalar(schema.preferredPairDelimiter)) - let emptyPairs = String(repeating: pairDelimiter, count: n) - - url[keyPath: component] = emptyPairs - XCTAssertEqual(url.serialized(), "http://example/\(prefix)\(emptyPairs)") - XCTAssertEqual(url[keyPath: component], emptyPairs) - checkViewIsEmptyList() - } - - // A pair consisting of an empty key and empty value (e.g. "=&=&=") is NOT the same as an empty pair. - // Even if that is all the URL component contains, we should not get an empty list of pairs. - for n in 1...5 { - let pairDelimiter = UnicodeScalar(schema.preferredPairDelimiter).description - let keyValueDelimiter = UnicodeScalar(schema.preferredKeyValueDelimiter).description - let keyValueString = repeatElement(keyValueDelimiter, count: n).joined(separator: pairDelimiter) - - url[keyPath: component] = keyValueString - XCTAssertEqual(url.serialized(), "http://example/\(prefix)\(keyValueString)") - XCTAssertEqual(url[keyPath: component], keyValueString) - - let view = getView(url) - XCTAssertEqual(view.isEmpty, false) - XCTAssertEqual(view.count, n) - for pair in view { - XCTAssertEqual(pair.key, "") - XCTAssertEqual(pair.value, "") + func assertKeyValuePairsIsEmptyList(_ url: WebURL) { + let kvps = url.keyValuePairs(in: component, schema: schema) + XCTAssertEqual(kvps.isEmpty, true) + CollectionChecker.check(kvps) + for kvp in kvps { + XCTFail("KeyValuePairs was not empty - found \(kvp)") } - CollectionChecker.check(view) } - } - - _testEmptyCollection(\.query, prefix: "?", view: { $0.queryParams }) - _testEmptyCollection(\.fragment, prefix: "#", view: { $0.keyValuePairs(in: .fragment, schema: CommaSeparated()) }) - } - func testKeyLookupSubscript() { - - func _testKeyLookupSubscript(_ kvps: WebURL.KeyValuePairs) { + let componentKeyPath: WritableKeyPath + switch component.value { + case .query: componentKeyPath = \.query + case .fragment: componentKeyPath = \.fragment + } - // Single key lookup. - // This should be equivalent to 'kvps.first { $0.key == TheKey }?.value' + // URL Component is nil. Should be an empty list. - // Non-escaped, unique key. - XCTAssertEqual(kvps["a"], "b") - XCTAssertEqual(kvps["emoji"], "👀") + do { + let url = WebURL(checkpoints[0])! + XCTAssertEqual(url.serialized(), checkpoints[0]) + XCTAssertEqual(url[keyPath: componentKeyPath], nil) + assertKeyValuePairsIsEmptyList(url) + } - // Key requires decoding. - XCTAssertEqual(kvps["mixed space & plus"], "d") - XCTAssertEqual(kvps["1 + 1 ="], "2") + // URL component is the empty string. Should be an empty list. - // Duplicate key. Lookup returns the first value. - XCTAssertEqual(kvps["dup"], "e") + do { + let url = WebURL(checkpoints[1])! + XCTAssertEqual(url.serialized(), checkpoints[1]) + XCTAssertEqual(url[keyPath: componentKeyPath], "") + assertKeyValuePairsIsEmptyList(url) + } - // Empty key/value. - XCTAssertEqual(kvps[""], "foo") - XCTAssertEqual(kvps["noval"], "") + // URL component consists only of empty key-value pairs (e.g. "&&&"). Should be an empty list. (x4) - // Unicode canonical equivalence. - XCTAssertEqual(kvps["jalapen\u{0303}os"], "nfd") - XCTAssertEqual(kvps["jalape\u{00F1}os"], "nfd") + for i in 2..<6 { + let url = WebURL(checkpoints[i])! + XCTAssertEqual(url.serialized(), checkpoints[i]) + XCTAssertFalse(url[keyPath: componentKeyPath]!.contains(where: { !schema.isPairDelimiter($0.asciiValue!) })) + assertKeyValuePairsIsEmptyList(url) + } - // Non-present key. - XCTAssertEqual(kvps["doesNotExist"], nil) - XCTAssertEqual(kvps["jalapenos"], nil) - XCTAssertEqual(kvps["DUP"], nil) + // URL component consists of a string of pairs with empty keys and values (e.g. "=&=&="). + // Should NOT be an empty list. (x4) - // Multiple key lookup. - // Each key should be looked up as above. + for i in 6..<10 { + let url = WebURL(checkpoints[i])! + XCTAssertEqual(url.serialized(), checkpoints[i]) - XCTAssertEqual(kvps["dup", "dup"], ("e", "e")) - XCTAssertEqual(kvps["jalapen\u{0303}os", "emoji", "jalape\u{00F1}os"], ("nfd", "👀", "nfd")) - XCTAssertEqual(kvps["1 + 1 =", "dup", "", "mixed space & plus"], ("2", "e", "foo", "d")) - XCTAssertEqual(kvps["noval", "doesNotExist", "DUP"], ("", nil, nil)) + let kvps = url.keyValuePairs(in: component, schema: schema) + XCTAssertEqual(kvps.count, 1 + i - 6) + for pair in kvps { + XCTAssertEqual(pair.encodedKey, "") + XCTAssertEqual(pair.encodedValue, "") + XCTAssertEqual(pair.key, "") + XCTAssertEqual(pair.value, "") + } + CollectionChecker.check(kvps) + } } // Form encoding in the query. - do { - let url = WebURL(#"http://example/?a=b&mixed%20space+%26+plus=d&dup=e&=foo&noval&emoji=👀&jalapen\#u{0303}os=nfd&dup=f&jalape\#u{00F1}os=nfc&1+%2B+1+%3D=2#a=z"#)! - XCTAssertEqual(url.query, #"a=b&mixed%20space+%26+plus=d&dup=e&=foo&noval&emoji=%F0%9F%91%80&jalapen%CC%83os=nfd&dup=f&jalape%C3%B1os=nfc&1+%2B+1+%3D=2"#) - _testKeyLookupSubscript(url.queryParams) - } - - // Form encoding in the query (2). - // Semi-colons allowed as pair delimiters. - do { - let url = WebURL(#"http://example/?a=b&mixed%20space+%26+plus=d;dup=e&=foo&noval&emoji=👀;jalapen\#u{0303}os=nfd;dup=f&jalape\#u{00F1}os=nfc&1+%2B+1+%3D=2#a=z"#)! - XCTAssertEqual(url.query, #"a=b&mixed%20space+%26+plus=d;dup=e&=foo&noval&emoji=%F0%9F%91%80;jalapen%CC%83os=nfd;dup=f&jalape%C3%B1os=nfc&1+%2B+1+%3D=2"#) - _testKeyLookupSubscript(url.keyValuePairs(in: .query, schema: ExtendedForm(semicolonIsPairDelimiter: true))) - } - - // Custom schema in the fragment. - // '&', '=', and '+' are used without escaping. - do { - let url = WebURL(#"http://example/?a:z#a:b,mixed%20space%20&%20plus:d,dup:e,:foo,noval,emoji:👀,jalapen\#u{0303}os:nfd,dup:f,jalape\#u{00F1}os:nfc,1%20+%201%20=:2"#)! - XCTAssertEqual(url.fragment, #"a:b,mixed%20space%20&%20plus:d,dup:e,:foo,noval,emoji:%F0%9F%91%80,jalapen%CC%83os:nfd,dup:f,jalape%C3%B1os:nfc,1%20+%201%20=:2"#) - _testKeyLookupSubscript(url.keyValuePairs(in: .fragment, schema: CommaSeparated())) - } - } - func testAllValuesForKey() { - - func _testAllValuesForKey(_ kvps: WebURL.KeyValuePairs) { - - // Single value. - XCTAssertEqual(kvps.allValues(forKey: "mixed space & plus"), ["d"]) - XCTAssertEqual(kvps.allValues(forKey: "1 + 1 ="), ["2"]) + _testEmptyCollection( + component: .query, schema: .formEncoded, checkpoints: [ + "http://example/", + "http://example/?", - // Multiple values. - // Order must match that in the list. - XCTAssertEqual(kvps.allValues(forKey: "dup"), ["e", "f"]) + "http://example/?&", + "http://example/?&&", + "http://example/?&&&", + "http://example/?&&&&", - // Unicode canonical equivalence. - XCTAssertEqual(kvps.allValues(forKey: "jalapen\u{0303}os"), ["nfd", "nfc"]) - XCTAssertEqual(kvps.allValues(forKey: "jalape\u{00F1}os"), ["nfd", "nfc"]) + "http://example/?=", + "http://example/?=&=", + "http://example/?=&=&=", + "http://example/?=&=&=&=", + ]) - // Empty key/value. - XCTAssertEqual(kvps.allValues(forKey: ""), ["foo"]) - XCTAssertEqual(kvps.allValues(forKey: "noval"), [""]) + // Form encoding in the query (2). + // Semicolons are also allowed as pair delimiters. - // Non-present keys. - XCTAssertEqual(kvps.allValues(forKey: "doesNotExist"), []) - XCTAssertEqual(kvps.allValues(forKey: "DUP"), []) - } + _testEmptyCollection( + component: .query, schema: ExtendedForm(semicolonIsPairDelimiter: true), checkpoints: [ + "http://example/", + "http://example/?", - // Form encoding in the query. - do { - let url = WebURL(#"http://example/?a=b&mixed%20space+%26+plus=d&dup=e&=foo&noval&emoji=👀&jalapen\#u{0303}os=nfd&dup=f&jalape\#u{00F1}os=nfc&1+%2B+1+%3D=2#a=z"#)! - XCTAssertEqual(url.query, #"a=b&mixed%20space+%26+plus=d&dup=e&=foo&noval&emoji=%F0%9F%91%80&jalapen%CC%83os=nfd&dup=f&jalape%C3%B1os=nfc&1+%2B+1+%3D=2"#) - _testAllValuesForKey(url.queryParams) - } + "http://example/?;", + "http://example/?&;", + "http://example/?&;&", + "http://example/?;&;&", - // Form encoding in the query (2). - // Semi-colons allowed as pair delimiters. - do { - let url = WebURL(#"http://example/?a=b&mixed%20space+%26+plus=d;dup=e&=foo&noval&emoji=👀;jalapen\#u{0303}os=nfd;dup=f&jalape\#u{00F1}os=nfc&1+%2B+1+%3D=2#a=z"#)! - XCTAssertEqual(url.query, #"a=b&mixed%20space+%26+plus=d;dup=e&=foo&noval&emoji=%F0%9F%91%80;jalapen%CC%83os=nfd;dup=f&jalape%C3%B1os=nfc&1+%2B+1+%3D=2"#) - _testAllValuesForKey(url.keyValuePairs(in: .query, schema: ExtendedForm(semicolonIsPairDelimiter: true))) - } + "http://example/?=", + "http://example/?=;=", + "http://example/?=&=;=", + "http://example/?=;=;=;=", + ]) // Custom schema in the fragment. - // '&', '=', and '+' are used without escaping. - do { - let url = WebURL(#"http://example/?a:z#a:b,mixed%20space%20&%20plus:d,dup:e,:foo,noval,emoji:👀,jalapen\#u{0303}os:nfd,dup:f,jalape\#u{00F1}os:nfc,1%20+%201%20=:2"#)! - XCTAssertEqual(url.fragment, #"a:b,mixed%20space%20&%20plus:d,dup:e,:foo,noval,emoji:%F0%9F%91%80,jalapen%CC%83os:nfd,dup:f,jalape%C3%B1os:nfc,1%20+%201%20=:2"#) - _testAllValuesForKey(url.keyValuePairs(in: .fragment, schema: CommaSeparated())) - } + + _testEmptyCollection( + component: .fragment, schema: CommaSeparated(), checkpoints: [ + "http://example/", + "http://example/#", + + "http://example/#,", + "http://example/#,,", + "http://example/#,,,", + "http://example/#,,,,", + + "http://example/#:", + "http://example/#:,:", + "http://example/#:,:,:", + "http://example/#:,:,:,:", + ]) } } + // ------------------------------- // MARK: - Writing: By Location // ------------------------------- -// replaceSubrange +// replaceSubrange(_:with:) + extension KeyValuePairsTests { + /// Tests using `replaceSubrange(_:with:)` to replace a contiguous region of key-value pairs. + /// + /// This test only covers partial replacements: the list of pairs does not start empty, + /// and some of its initial elements will be present in the result. + /// + /// **Test ranges**: + /// + /// - Anchored to the start + /// - Not anchored (floating in the middle) + /// - Anchored to the end + /// + /// **Operations**: + /// + /// | Operation | Removing | Inserting | + /// |-----------|---------------|----------------| + /// | No-Op | No elements | No elements | + /// | Insertion | No elements | Some elements | + /// |-----------|---------------|----------------| + /// | Deletion | Some elements | No elements | + /// | Shrink | Some elements | Fewer elements | + /// | Grow | Some elements | More elements | + /// + /// **Tests are repeated**: + /// + /// 1. In various URL components, + /// 2. Using a variety of schemas with different escaping rules and delimiters, and + /// 3. With empty pairs inserted at various points in the URL component. + /// func testReplaceSubrange_partialReplacement() { - // Test ranges: - // - // - At the start - // - In the middle - // - At the end - // - // and with the operation: - // - // - Inserting (removing no elements, inserting some elements) - // - Removing (removing some elements, inserting no elements) - // - Shrinking (removing some elements, inserting fewer elements) - // - Growing (removing some elements, inserting more elements) - // - // This test only covers partial replacements: the key-value string must already have some elements, - // and some of those elements will still be present in the result. - - func _testReplaceSubrange( - url urlString: String, component: KeyValuePairsSupportedComponent, schema: Schema, checkpoints: [String] - ) where Schema: KeyValueStringSchema { + func _testReplaceSubrange( + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema, checkpoints: [String] + ) { let url = WebURL(urlString)! XCTAssertEqual(url.serialized(), urlString) - let initialPairs = url.keyValuePairs(in: component, schema: schema).map { ($0.key, $0.value) } + let initialPairs = url.keyValuePairs(in: component, schema: schema).map { KeyValuePair($0) } let initialCount = initialPairs.count - precondition(initialCount >= 4, "We need at least 4 key-value pairs to run this test") + precondition(initialCount >= 4, "URL component must contain at least 4 key-value pairs to run this test") func replace(offsets: Range, with newPairs: [(String, String)]) -> WebURL { @@ -494,82 +334,104 @@ extension KeyValuePairsTests { } XCTAssertURLIsIdempotent(url) - do { - let kvps = url.keyValuePairs(in: component, schema: schema) - XCTAssertEqual( - kvps.index(kvps.startIndex, offsetBy: offsets.lowerBound), - newPairIndexes.lowerBound - ) - XCTAssertEqual( - kvps.index(kvps.startIndex, offsetBy: offsets.upperBound - offsets.count + newPairs.count), - newPairIndexes.upperBound - ) - XCTAssertEqualKeyValuePairs(kvps[newPairIndexes], newPairs) - } - do { - var expected = initialPairs - expected.replaceSubrange(offsets, with: newPairs) - XCTAssertEqualKeyValuePairs(url.keyValuePairs(in: component, schema: schema), expected) - } + let kvps = url.keyValuePairs(in: component, schema: schema) + let expectedNewPairs = newPairs.map { KeyValuePair(key: $0.0, value: $0.1) } + + // Check that the returned indexes are at the expected offsets, + // and contain the expected new pairs. + + XCTAssertEqual( + kvps.index(kvps.startIndex, offsetBy: offsets.lowerBound), + newPairIndexes.lowerBound + ) + XCTAssertEqual( + kvps.index(kvps.startIndex, offsetBy: offsets.upperBound - offsets.count + newPairs.count), + newPairIndexes.upperBound + ) + XCTAssertEqualKeyValuePairs(kvps[newPairIndexes], expectedNewPairs) + + // Perform the same operation on an Array, + // and check that our operation is consistent with those semantics. + + var expectedList = initialPairs + expectedList.replaceSubrange(offsets, with: expectedNewPairs) + XCTAssertEqualKeyValuePairs(kvps, expectedList) return url } var result: WebURL + // Do nothing at the start. + result = replace(offsets: 0..<0, with: []) + XCTAssertEqual(result.serialized(), checkpoints[0]) + + // Do nothing in the middle. + result = replace(offsets: 2..<2, with: []) + XCTAssertEqual(result.serialized(), checkpoints[1]) + + // Do nothing at the end. + result = replace(offsets: initialCount.. (remove middle pair) -> "url:/?one=a&&&&&&&three=c" + // + // b) Replace the region of the string from the start of x's key, up to the start of y's key, + // and removing any empty pairs in that space. + // "url:/?one=a&&&two=b&&&&three=c" -> (remove middle pair) -> "url:/?one=a&&&three=c" + // | x | | y | + // | x.. (append "bar") -> "url:/?foo&bar" NOT "url:/?foo&&bar" + // ^ ^^ + // + // Examples of this happening can be seen in the strings below, annotated [1], [2], [3], and [4]. + // + // - Special Characters in existing content. + // + // Notwithstanding [3] above, content outside of the range `x.. "httpx://example/?foo&bar", not "httpx://example/?foo&&bar" - // ^ ^^ - - // [3] - // VVV - "http://example/?inserted=one&\(Self.SpecialCharacters_Escaped_Form)=two&first[+^x%`?]~=0&&&second[+^x%`?]~=1&&&third[+^x%`?]~=2&&&fourth[+^x%`?]~=3&&&#frag", - // [1] [2] - // VVV V - "http://example/?&&&first[+^x%`?]~=0&&&second[+^x%`?]~=1&&&inserted=one&another%20insert=\(Self.SpecialCharacters_Escaped_Form)&third[+^x%`?]~=2&&&fourth[+^x%`?]~=3&&&#frag", - // [4] - // VVV - "http://example/?&&&first[+^x%`?]~=0&&&second[+^x%`?]~=1&&&third[+^x%`?]~=2&&&fourth[+^x%`?]~=3&&&inserted=one&another%20insert=two#frag", - - "http://example/?third[+^x%`?]~=2&&&fourth[+^x%`?]~=3&&&#frag", - "http://example/?&&&first[+^x%`?]~=0&&&fourth[+^x%`?]~=3&&&#frag", - "http://example/?&&&first[+^x%`?]~=0&&&second[+^x%`?]~=1&&#frag", - - "http://example/?shrink=start&third[+^x%`?]~=2&&&fourth[+^x%`?]~=3&&&#frag", - "http://example/?&&&first[+^x%`?]~=0&&&shrink=\(Self.SpecialCharacters_Escaped_Form)&fourth[+^x%`?]~=3&&&#frag", - "http://example/?&&&first[+^x%`?]~=0&&&second[+^x%`?]~=1&&&shrink=end#frag", - - "http://example/?grow=start&grow%20s=sp%20ace&\(Self.SpecialCharacters_Escaped_Form)=%F0%9F%A5%B8&third[+^x%`?]~=2&&&fourth[+^x%`?]~=3&&&#frag", - "http://example/?&&&first[+^x%`?]~=0&&&second[+^x%`?]~=1&&&grow=mid&%F0%9F%8C%B1=%F0%9F%8C%BB&grow=\(Self.SpecialCharacters_Escaped_Form)&fourth[+^x%`?]~=3&&&#frag", - "http://example/?&&&first[+^x%`?]~=0&&&second[+^x%`?]~=1&&&third[+^x%`?]~=2&&&grow=end&=noname&=#frag", + "http://example/?grow=start&grow%20s=sp%20ace&\(Self.SpecialCharacters_Escaped_Form)=%F0%9F%A5%B8&third\(s)=2&&&fourth\(s)=3&&&#frag", + "http://example/?&&&first\(s)=0&&&second\(s)=1&&&grow=mid&%F0%9F%8C%B1=%F0%9F%8C%BB&grow=\(Self.SpecialCharacters_Escaped_Form)&fourth\(s)=3&&&#frag", + "http://example/?&&&first\(s)=0&&&second\(s)=1&&&third\(s)=2&&&grow=end&=noname&=#frag", ] ) } + /// Tests using `replaceSubrange(_:with:)` to replace a contiguous region of key-value pairs. + /// + /// This test covers replacements over the range `kvps.startIndex..( - url urlString: String, component: KeyValuePairsSupportedComponent, schema: Schema, checkpoints: [String] - ) where Schema: KeyValueStringSchema { + func _testReplaceSubrange( + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema, checkpoints: [String] + ) { let url = WebURL(urlString)! XCTAssertEqual(url.serialized(), urlString) - // Replace everything with empty collection. + // Replace everything with an empty collection. + do { var url = url let range = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in @@ -714,15 +633,15 @@ extension KeyValuePairsTests { XCTAssertURLIsIdempotent(url) let kvps = url.keyValuePairs(in: component, schema: schema) - for _ in kvps { XCTFail("Should be empty") } - - XCTAssertEqual(kvps.endIndex, range.lowerBound) - XCTAssertEqual(kvps.endIndex, range.upperBound) + XCTAssertEqual(kvps.startIndex..( - url urlString: String, component: KeyValuePairsSupportedComponent, schema: Schema, checkpoints: [String] - ) where Schema: KeyValueStringSchema { + func _testInsertCollection( + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema, checkpoints: [String] + ) { let url = WebURL(urlString)! XCTAssertEqual(url.serialized(), urlString) @@ -857,17 +807,24 @@ extension KeyValuePairsTests { return newPairIndexes } XCTAssertURLIsIdempotent(url) - do { - let kvps = url.keyValuePairs(in: component, schema: schema) - XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: offset), newPairIndexes.lowerBound) - XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: offset + newPairs.count), newPairIndexes.upperBound) - XCTAssertEqualKeyValuePairs(kvps[newPairIndexes], newPairs) - } - do { - var expected = initialPairs - expected.insert(contentsOf: newPairs.lazy.map { KeyValuePair($0) }, at: offset) - XCTAssertEqualKeyValuePairs(url.keyValuePairs(in: component, schema: schema), expected) - } + + let kvps = url.keyValuePairs(in: component, schema: schema) + let expectedNewPairs = newPairs.map { KeyValuePair(key: $0.0, value: $0.1) } + + // Check that the returned indexes are at the expected offsets, + // and contain the expected new pairs. + + XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: offset), newPairIndexes.lowerBound) + XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: offset + newPairs.count), newPairIndexes.upperBound) + XCTAssertEqualKeyValuePairs(kvps[newPairIndexes], expectedNewPairs) + + // Perform the same operation on an Array, + // and check that our operation is consistent with those semantics. + + var expectedList = initialPairs + expectedList.insert(contentsOf: expectedNewPairs, at: offset) + XCTAssertEqualKeyValuePairs(kvps, expectedList) + return url } @@ -880,15 +837,15 @@ extension KeyValuePairsTests { ("cafe\u{0301}", "caf\u{00E9}") ] - // Insert at the front + // Insert multiple elements at the front. result = insert(pairsToInsert, atOffset: 0) XCTAssertEqual(result.serialized(), checkpoints[0]) - // Insert in the middle + // Insert multiple elements in the middle. result = insert(pairsToInsert, atOffset: min(initialPairs.count, 1)) XCTAssertEqual(result.serialized(), checkpoints[1]) - // Insert at the end. + // Insert multiple elements at the end. result = insert(pairsToInsert, atOffset: initialPairs.count) XCTAssertEqual(result.serialized(), checkpoints[2]) @@ -917,103 +874,114 @@ extension KeyValuePairsTests { XCTAssertEqual(result.serialized(), checkpoints[8]) } - // Form-encoded in the query (starts with nil query). + // Form-encoded in the query. + // Initial query = nil. + _testInsertCollection( - url: "http://example/", + url: "http://example/#frag", component: .query, schema: .formEncoded, checkpoints: [ - "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9", - "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9", - "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9", + "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9#frag", + "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9#frag", + "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9#frag", - "http://example/?some=element", - "http://example/?some=element", - "http://example/?some=element", + "http://example/?some=element#frag", + "http://example/?some=element#frag", + "http://example/?some=element#frag", - "http://example/", - "http://example/", - "http://example/", + "http://example/#frag", + "http://example/#frag", + "http://example/#frag", ] ) - // Form-encoded in the query (starts with empty query). + // Form-encoded in the query (2). + // Initial query = empty string. + _testInsertCollection( - url: "http://example/?", + url: "http://example/?#frag", component: .query, schema: .formEncoded, checkpoints: [ - "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9", - "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9", - "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9", + "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9#frag", + "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9#frag", + "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9#frag", - "http://example/?some=element", - "http://example/?some=element", - "http://example/?some=element", + "http://example/?some=element#frag", + "http://example/?some=element#frag", + "http://example/?some=element#frag", - "http://example/", - "http://example/", - "http://example/", + "http://example/#frag", + "http://example/#frag", + "http://example/#frag", ] ) - // Form-encoded in the query (starts with non-empty query, but empty list). + // Form-encoded in the query (3). + // Initial query = non-empty string, empty list of pairs. + _testInsertCollection( - url: "http://example/?&&&", + url: "http://example/?&&&#frag", component: .query, schema: .formEncoded, checkpoints: [ - "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9", - "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9", - "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9", + "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9#frag", + "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9#frag", + "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9#frag", - "http://example/?some=element", - "http://example/?some=element", - "http://example/?some=element", + "http://example/?some=element#frag", + "http://example/?some=element#frag", + "http://example/?some=element#frag", - "http://example/", - "http://example/", - "http://example/", + "http://example/#frag", + "http://example/#frag", + "http://example/#frag", ] ) - // Form-encoded in the query (starts with non-empty query, encodeSpaceAsPlus = true). + // Form-encoded in the query (4). + // Initial query = non-empty list of pairs. Inserted spaces are encoded using "+" signs. + _testInsertCollection( - url: "http://example/?foo=bar&baz=qux", + url: "http://example/?foo=bar&baz=qux#frag", component: .query, schema: ExtendedForm(encodeSpaceAsPlus: true), checkpoints: [ - "http://example/?inserted=some+value&\(Self.SpecialCharacters_Escaped_Form_Plus)=\(Self.SpecialCharacters_Escaped_Form_Plus)&=&cafe%CC%81=caf%C3%A9&foo=bar&baz=qux", - "http://example/?foo=bar&inserted=some+value&\(Self.SpecialCharacters_Escaped_Form_Plus)=\(Self.SpecialCharacters_Escaped_Form_Plus)&=&cafe%CC%81=caf%C3%A9&baz=qux", - "http://example/?foo=bar&baz=qux&inserted=some+value&\(Self.SpecialCharacters_Escaped_Form_Plus)=\(Self.SpecialCharacters_Escaped_Form_Plus)&=&cafe%CC%81=caf%C3%A9", + "http://example/?inserted=some+value&\(Self.SpecialCharacters_Escaped_Form_Plus)=\(Self.SpecialCharacters_Escaped_Form_Plus)&=&cafe%CC%81=caf%C3%A9&foo=bar&baz=qux#frag", + "http://example/?foo=bar&inserted=some+value&\(Self.SpecialCharacters_Escaped_Form_Plus)=\(Self.SpecialCharacters_Escaped_Form_Plus)&=&cafe%CC%81=caf%C3%A9&baz=qux#frag", + "http://example/?foo=bar&baz=qux&inserted=some+value&\(Self.SpecialCharacters_Escaped_Form_Plus)=\(Self.SpecialCharacters_Escaped_Form_Plus)&=&cafe%CC%81=caf%C3%A9#frag", - "http://example/?some=element&foo=bar&baz=qux", - "http://example/?foo=bar&some=element&baz=qux", - "http://example/?foo=bar&baz=qux&some=element", + "http://example/?some=element&foo=bar&baz=qux#frag", + "http://example/?foo=bar&some=element&baz=qux#frag", + "http://example/?foo=bar&baz=qux&some=element#frag", - "http://example/?foo=bar&baz=qux", - "http://example/?foo=bar&baz=qux", - "http://example/?foo=bar&baz=qux", + "http://example/?foo=bar&baz=qux#frag", + "http://example/?foo=bar&baz=qux#frag", + "http://example/?foo=bar&baz=qux#frag", ] ) - // Form-encoded in the query (starts with non-empty query, empty key-value pairs). + // Form-encoded in the query (5). + // Initial query = non-empty list of pairs. Component includes empty pairs. + _testInsertCollection( - url: "http://example/?&&&foo=bar&&&baz=qux&&&", + url: "http://example/?&&&foo=bar&&&baz=qux&&&#frag", component: .query, schema: .formEncoded, checkpoints: [ - "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9&foo=bar&&&baz=qux&&&", - "http://example/?&&&foo=bar&&&inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9&baz=qux&&&", - "http://example/?&&&foo=bar&&&baz=qux&&&inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9", + "http://example/?inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9&foo=bar&&&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&baz=qux&&&inserted=some%20value&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&=&cafe%CC%81=caf%C3%A9#frag", - "http://example/?some=element&foo=bar&&&baz=qux&&&", - "http://example/?&&&foo=bar&&&some=element&baz=qux&&&", - "http://example/?&&&foo=bar&&&baz=qux&&&some=element", + "http://example/?some=element&foo=bar&&&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&some=element&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&baz=qux&&&some=element#frag", - "http://example/?foo=bar&&&baz=qux&&&", - "http://example/?&&&foo=bar&&&baz=qux&&&", - "http://example/?&&&foo=bar&&&baz=qux&&&", + "http://example/?foo=bar&&&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&baz=qux&&&#frag", ] ) - // Custom schema in the fragment (minimal percent-encoding). + // Custom schema in the fragment. + _testInsertCollection( url: "http://example/?srch#frag:ment,stuff", - component: .fragment, schema: CommaSeparated(minimalPercentEncoding: true), checkpoints: [ - "http://example/?srch#inserted:some%20value,\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag):\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag),:,cafe%CC%81:caf%C3%A9,frag:ment,stuff", - "http://example/?srch#frag:ment,inserted:some%20value,\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag):\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag),:,cafe%CC%81:caf%C3%A9,stuff", - "http://example/?srch#frag:ment,stuff,inserted:some%20value,\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag):\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag),:,cafe%CC%81:caf%C3%A9", + component: .fragment, schema: CommaSeparated(), checkpoints: [ + "http://example/?srch#inserted:some%20value,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):\(Self.SpecialCharacters_Escaped_CommaSep_Frag),:,cafe%CC%81:caf%C3%A9,frag:ment,stuff", + "http://example/?srch#frag:ment,inserted:some%20value,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):\(Self.SpecialCharacters_Escaped_CommaSep_Frag),:,cafe%CC%81:caf%C3%A9,stuff", + "http://example/?srch#frag:ment,stuff,inserted:some%20value,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):\(Self.SpecialCharacters_Escaped_CommaSep_Frag),:,cafe%CC%81:caf%C3%A9", "http://example/?srch#some:element,frag:ment,stuff", "http://example/?srch#frag:ment,some:element,stuff", @@ -1026,11 +994,29 @@ extension KeyValuePairsTests { ) } + /// Tests using `insert(key:value:at:)` to insert a single key-value pair. + /// + /// The inserted pairs have interesting features, such as special characters, + /// empty key names or values, Unicode, etc. + /// + /// **Insertion Points**: + /// + /// 1. At the start of the list + /// 2. In the middle of the list + /// 3. At the end of the list (appending) + /// + /// **Tests are repeated**: + /// + /// 1. In various URL components, + /// 2. Using a variety of schemas with different escaping rules and delimiters, and + /// 3. With empty pairs inserted at various points in the URL component. + /// func testInsertOne() { - func _testInsertOne( - url urlString: String, component: KeyValuePairsSupportedComponent, schema: Schema, checkpoints: [String] - ) where Schema: KeyValueStringSchema { + func _testInsertOne( + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema, checkpoints: [String] + ) { let url = WebURL(urlString)! XCTAssertEqual(url.serialized(), urlString) @@ -1041,26 +1027,33 @@ extension KeyValuePairsTests { var url = url let newPairIndexes = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in - let newIdxs = kvps.insert(key: newPair.0, value: newPair.1, at: kvps.index(kvps.startIndex, offsetBy: offset)) + let insertionPoint = kvps.index(kvps.startIndex, offsetBy: offset) + let newPairIndexes = kvps.insert(key: newPair.0, value: newPair.1, at: insertionPoint) XCTAssertKeyValuePairCacheIsAccurate(kvps) - return newIdxs + return newPairIndexes } XCTAssertURLIsIdempotent(url) - do { - let kvps = url.keyValuePairs(in: component, schema: schema) - XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: offset), newPairIndexes.lowerBound) - XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: offset + 1), newPairIndexes.upperBound) - XCTAssertEqualKeyValuePairs(kvps[newPairIndexes], [newPair]) - } - do { - var expected = initialPairs - expected.insert(KeyValuePair(key: newPair.0, value: newPair.1), at: offset) - XCTAssertEqualKeyValuePairs(url.keyValuePairs(in: component, schema: schema), expected) - } + + let kvps = url.keyValuePairs(in: component, schema: schema) + let expectedNewPair = KeyValuePair(key: newPair.0, value: newPair.1) + + // Check that the returned indexes are at the expected offsets, + // and contain the expected new pairs. + + XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: offset), newPairIndexes.lowerBound) + XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: offset + 1), newPairIndexes.upperBound) + XCTAssertEqualKeyValuePairs(kvps[newPairIndexes], [expectedNewPair]) + + // Perform the same operation on an Array, + // and check that our operation is consistent with those semantics. + + var expectedList = initialPairs + expectedList.insert(expectedNewPair, at: offset) + XCTAssertEqualKeyValuePairs(kvps, expectedList) + return url } - var result: WebURL var checkpointIdx = 0 let pairsToTest = [ @@ -1073,7 +1066,7 @@ extension KeyValuePairsTests { for pair in pairsToTest { // Insert at the front - result = insert(pair, atOffset: 0) + var result = insert(pair, atOffset: 0) XCTAssertEqual(result.serialized(), checkpoints[checkpointIdx]) // Insert in the middle @@ -1088,140 +1081,151 @@ extension KeyValuePairsTests { } } - // Form-encoded in the query (starts with nil query). + // Form-encoded in the query. + // Initial query = nil. + _testInsertOne( - url: "http://example/", + url: "http://example/#frag", component: .query, schema: .formEncoded, checkpoints: [ - "http://example/?inserted=some%20value", - "http://example/?inserted=some%20value", - "http://example/?inserted=some%20value", + "http://example/?inserted=some%20value#frag", + "http://example/?inserted=some%20value#frag", + "http://example/?inserted=some%20value#frag", - "http://example/?cafe%CC%81=caf%C3%A9", - "http://example/?cafe%CC%81=caf%C3%A9", - "http://example/?cafe%CC%81=caf%C3%A9", + "http://example/?cafe%CC%81=caf%C3%A9#frag", + "http://example/?cafe%CC%81=caf%C3%A9#frag", + "http://example/?cafe%CC%81=caf%C3%A9#frag", - "http://example/?=\(Self.SpecialCharacters_Escaped_Form)", - "http://example/?=\(Self.SpecialCharacters_Escaped_Form)", - "http://example/?=\(Self.SpecialCharacters_Escaped_Form)", + "http://example/?=\(Self.SpecialCharacters_Escaped_Form)#frag", + "http://example/?=\(Self.SpecialCharacters_Escaped_Form)#frag", + "http://example/?=\(Self.SpecialCharacters_Escaped_Form)#frag", - "http://example/?\(Self.SpecialCharacters_Escaped_Form)=", - "http://example/?\(Self.SpecialCharacters_Escaped_Form)=", - "http://example/?\(Self.SpecialCharacters_Escaped_Form)=", + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=#frag", + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=#frag", + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=#frag", - "http://example/?=", - "http://example/?=", - "http://example/?=", + "http://example/?=#frag", + "http://example/?=#frag", + "http://example/?=#frag", ] ) - // Form-encoded in the query (starts with empty query). + // Form-encoded in the query (2). + // Initial query = empty string. + _testInsertOne( - url: "http://example/?", + url: "http://example/?#frag", component: .query, schema: .formEncoded, checkpoints: [ - "http://example/?inserted=some%20value", - "http://example/?inserted=some%20value", - "http://example/?inserted=some%20value", + "http://example/?inserted=some%20value#frag", + "http://example/?inserted=some%20value#frag", + "http://example/?inserted=some%20value#frag", - "http://example/?cafe%CC%81=caf%C3%A9", - "http://example/?cafe%CC%81=caf%C3%A9", - "http://example/?cafe%CC%81=caf%C3%A9", + "http://example/?cafe%CC%81=caf%C3%A9#frag", + "http://example/?cafe%CC%81=caf%C3%A9#frag", + "http://example/?cafe%CC%81=caf%C3%A9#frag", - "http://example/?=\(Self.SpecialCharacters_Escaped_Form)", - "http://example/?=\(Self.SpecialCharacters_Escaped_Form)", - "http://example/?=\(Self.SpecialCharacters_Escaped_Form)", + "http://example/?=\(Self.SpecialCharacters_Escaped_Form)#frag", + "http://example/?=\(Self.SpecialCharacters_Escaped_Form)#frag", + "http://example/?=\(Self.SpecialCharacters_Escaped_Form)#frag", - "http://example/?\(Self.SpecialCharacters_Escaped_Form)=", - "http://example/?\(Self.SpecialCharacters_Escaped_Form)=", - "http://example/?\(Self.SpecialCharacters_Escaped_Form)=", + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=#frag", + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=#frag", + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=#frag", - "http://example/?=", - "http://example/?=", - "http://example/?=", + "http://example/?=#frag", + "http://example/?=#frag", + "http://example/?=#frag", ] ) - // Form-encoded in the query (starts with non-empty query, but empty list). + // Form-encoded in the query (3). + // Initial query = non-empty string, empty list of pairs. + _testInsertOne( - url: "http://example/?&&&", + url: "http://example/?&&&#frag", component: .query, schema: .formEncoded, checkpoints: [ - "http://example/?inserted=some%20value", - "http://example/?inserted=some%20value", - "http://example/?inserted=some%20value", + "http://example/?inserted=some%20value#frag", + "http://example/?inserted=some%20value#frag", + "http://example/?inserted=some%20value#frag", - "http://example/?cafe%CC%81=caf%C3%A9", - "http://example/?cafe%CC%81=caf%C3%A9", - "http://example/?cafe%CC%81=caf%C3%A9", + "http://example/?cafe%CC%81=caf%C3%A9#frag", + "http://example/?cafe%CC%81=caf%C3%A9#frag", + "http://example/?cafe%CC%81=caf%C3%A9#frag", - "http://example/?=\(Self.SpecialCharacters_Escaped_Form)", - "http://example/?=\(Self.SpecialCharacters_Escaped_Form)", - "http://example/?=\(Self.SpecialCharacters_Escaped_Form)", + "http://example/?=\(Self.SpecialCharacters_Escaped_Form)#frag", + "http://example/?=\(Self.SpecialCharacters_Escaped_Form)#frag", + "http://example/?=\(Self.SpecialCharacters_Escaped_Form)#frag", - "http://example/?\(Self.SpecialCharacters_Escaped_Form)=", - "http://example/?\(Self.SpecialCharacters_Escaped_Form)=", - "http://example/?\(Self.SpecialCharacters_Escaped_Form)=", + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=#frag", + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=#frag", + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=#frag", - "http://example/?=", - "http://example/?=", - "http://example/?=", + "http://example/?=#frag", + "http://example/?=#frag", + "http://example/?=#frag", ] ) - // Form-encoded in the query (starts with non-empty query, encodeSpaceAsPlus = true). + // Form-encoded in the query (4). + // Initial query = non-empty list of pairs. Inserted spaces are encoded using "+" signs. + _testInsertOne( - url: "http://example/?foo=bar&baz=qux", + url: "http://example/?foo=bar&baz=qux#frag", component: .query, schema: ExtendedForm(encodeSpaceAsPlus: true), checkpoints: [ - "http://example/?inserted=some+value&foo=bar&baz=qux", - "http://example/?foo=bar&inserted=some+value&baz=qux", - "http://example/?foo=bar&baz=qux&inserted=some+value", + "http://example/?inserted=some+value&foo=bar&baz=qux#frag", + "http://example/?foo=bar&inserted=some+value&baz=qux#frag", + "http://example/?foo=bar&baz=qux&inserted=some+value#frag", - "http://example/?cafe%CC%81=caf%C3%A9&foo=bar&baz=qux", - "http://example/?foo=bar&cafe%CC%81=caf%C3%A9&baz=qux", - "http://example/?foo=bar&baz=qux&cafe%CC%81=caf%C3%A9", + "http://example/?cafe%CC%81=caf%C3%A9&foo=bar&baz=qux#frag", + "http://example/?foo=bar&cafe%CC%81=caf%C3%A9&baz=qux#frag", + "http://example/?foo=bar&baz=qux&cafe%CC%81=caf%C3%A9#frag", - "http://example/?=\(Self.SpecialCharacters_Escaped_Form_Plus)&foo=bar&baz=qux", - "http://example/?foo=bar&=\(Self.SpecialCharacters_Escaped_Form_Plus)&baz=qux", - "http://example/?foo=bar&baz=qux&=\(Self.SpecialCharacters_Escaped_Form_Plus)", + "http://example/?=\(Self.SpecialCharacters_Escaped_Form_Plus)&foo=bar&baz=qux#frag", + "http://example/?foo=bar&=\(Self.SpecialCharacters_Escaped_Form_Plus)&baz=qux#frag", + "http://example/?foo=bar&baz=qux&=\(Self.SpecialCharacters_Escaped_Form_Plus)#frag", - "http://example/?\(Self.SpecialCharacters_Escaped_Form_Plus)=&foo=bar&baz=qux", - "http://example/?foo=bar&\(Self.SpecialCharacters_Escaped_Form_Plus)=&baz=qux", - "http://example/?foo=bar&baz=qux&\(Self.SpecialCharacters_Escaped_Form_Plus)=", + "http://example/?\(Self.SpecialCharacters_Escaped_Form_Plus)=&foo=bar&baz=qux#frag", + "http://example/?foo=bar&\(Self.SpecialCharacters_Escaped_Form_Plus)=&baz=qux#frag", + "http://example/?foo=bar&baz=qux&\(Self.SpecialCharacters_Escaped_Form_Plus)=#frag", - "http://example/?=&foo=bar&baz=qux", - "http://example/?foo=bar&=&baz=qux", - "http://example/?foo=bar&baz=qux&=", + "http://example/?=&foo=bar&baz=qux#frag", + "http://example/?foo=bar&=&baz=qux#frag", + "http://example/?foo=bar&baz=qux&=#frag", ] ) - // Form-encoded in the query (starts with non-empty query, empty key-value pairs). + // Form-encoded in the query (5). + // Initial query = non-empty list of pairs. Component includes empty pairs. + _testInsertOne( - url: "http://example/?&&&foo=bar&&&baz=qux&&&", + url: "http://example/?&&&foo=bar&&&baz=qux&&&#frag", component: .query, schema: .formEncoded, checkpoints: [ - "http://example/?inserted=some%20value&foo=bar&&&baz=qux&&&", - "http://example/?&&&foo=bar&&&inserted=some%20value&baz=qux&&&", - "http://example/?&&&foo=bar&&&baz=qux&&&inserted=some%20value", + "http://example/?inserted=some%20value&foo=bar&&&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&inserted=some%20value&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&baz=qux&&&inserted=some%20value#frag", - "http://example/?cafe%CC%81=caf%C3%A9&foo=bar&&&baz=qux&&&", - "http://example/?&&&foo=bar&&&cafe%CC%81=caf%C3%A9&baz=qux&&&", - "http://example/?&&&foo=bar&&&baz=qux&&&cafe%CC%81=caf%C3%A9", + "http://example/?cafe%CC%81=caf%C3%A9&foo=bar&&&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&cafe%CC%81=caf%C3%A9&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&baz=qux&&&cafe%CC%81=caf%C3%A9#frag", - "http://example/?=\(Self.SpecialCharacters_Escaped_Form)&foo=bar&&&baz=qux&&&", - "http://example/?&&&foo=bar&&&=\(Self.SpecialCharacters_Escaped_Form)&baz=qux&&&", - "http://example/?&&&foo=bar&&&baz=qux&&&=\(Self.SpecialCharacters_Escaped_Form)", + "http://example/?=\(Self.SpecialCharacters_Escaped_Form)&foo=bar&&&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&=\(Self.SpecialCharacters_Escaped_Form)&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&baz=qux&&&=\(Self.SpecialCharacters_Escaped_Form)#frag", - "http://example/?\(Self.SpecialCharacters_Escaped_Form)=&foo=bar&&&baz=qux&&&", - "http://example/?&&&foo=bar&&&\(Self.SpecialCharacters_Escaped_Form)=&baz=qux&&&", - "http://example/?&&&foo=bar&&&baz=qux&&&\(Self.SpecialCharacters_Escaped_Form)=", + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=&foo=bar&&&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&\(Self.SpecialCharacters_Escaped_Form)=&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&baz=qux&&&\(Self.SpecialCharacters_Escaped_Form)=#frag", - "http://example/?=&foo=bar&&&baz=qux&&&", - "http://example/?&&&foo=bar&&&=&baz=qux&&&", - "http://example/?&&&foo=bar&&&baz=qux&&&=", + "http://example/?=&foo=bar&&&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&=&baz=qux&&&#frag", + "http://example/?&&&foo=bar&&&baz=qux&&&=#frag", ] ) - // Custom schema in the fragment (minimal percent-encoding). + // Custom schema in the fragment. + _testInsertOne( url: "http://example/?srch#frag:ment,stuff", - component: .fragment, schema: CommaSeparated(minimalPercentEncoding: true), checkpoints: [ + component: .fragment, schema: CommaSeparated(), checkpoints: [ "http://example/?srch#inserted:some%20value,frag:ment,stuff", "http://example/?srch#frag:ment,inserted:some%20value,stuff", "http://example/?srch#frag:ment,stuff,inserted:some%20value", @@ -1230,13 +1234,13 @@ extension KeyValuePairsTests { "http://example/?srch#frag:ment,cafe%CC%81:caf%C3%A9,stuff", "http://example/?srch#frag:ment,stuff,cafe%CC%81:caf%C3%A9", - "http://example/?srch#:\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag),frag:ment,stuff", - "http://example/?srch#frag:ment,:\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag),stuff", - "http://example/?srch#frag:ment,stuff,:\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag)", + "http://example/?srch#:\(Self.SpecialCharacters_Escaped_CommaSep_Frag),frag:ment,stuff", + "http://example/?srch#frag:ment,:\(Self.SpecialCharacters_Escaped_CommaSep_Frag),stuff", + "http://example/?srch#frag:ment,stuff,:\(Self.SpecialCharacters_Escaped_CommaSep_Frag)", - "http://example/?srch#\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag):,frag:ment,stuff", - "http://example/?srch#frag:ment,\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag):,stuff", - "http://example/?srch#frag:ment,stuff,\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag):", + "http://example/?srch#\(Self.SpecialCharacters_Escaped_CommaSep_Frag):,frag:ment,stuff", + "http://example/?srch#frag:ment,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):,stuff", + "http://example/?srch#frag:ment,stuff,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):", "http://example/?srch#:,frag:ment,stuff", "http://example/?srch#frag:ment,:,stuff", @@ -1250,11 +1254,32 @@ extension KeyValuePairsTests { extension KeyValuePairsTests { + /// Tests using `removeSubrange(_:)` to remove a contiguous region of key-value pairs. + /// + /// **Range locations**: + /// + /// - Anchored to the start + /// - Not anchored (floating in the middle) + /// - Anchored to the end + /// + /// **Range sizes**: + /// + /// - Empty + /// - Non-empty + /// - All elements (`startIndex..( - url urlString: String, component: KeyValuePairsSupportedComponent, schema: Schema, checkpoints: [String] - ) where Schema: KeyValueStringSchema { + func _testRemoveSubrange( + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema, checkpoints: [String] + ) { let url = WebURL(urlString)! XCTAssertEqual(url.serialized(), urlString) @@ -1262,6 +1287,7 @@ extension KeyValuePairsTests { let initialPairs = url.keyValuePairs(in: component, schema: schema).map { KeyValuePair($0) } func remove(offsets: Range) -> WebURL { + var url = url let idx = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in let lowerBound = kvps.index(kvps.startIndex, offsetBy: offsets.lowerBound) @@ -1273,43 +1299,56 @@ extension KeyValuePairsTests { XCTAssertURLIsIdempotent(url) let kvps = url.keyValuePairs(in: component, schema: schema) + + // Check that the returned indexes are at the expected offsets, + // and contain the expected new pairs. + XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: offsets.upperBound - offsets.count), idx) - var expected = initialPairs - expected.removeSubrange(offsets) - XCTAssertEqualKeyValuePairs(kvps, expected) + // Perform the same operation on an Array, + // and check that our operation is consistent with those semantics. + + var expectedList = initialPairs + expectedList.removeSubrange(offsets) + XCTAssertEqualKeyValuePairs(kvps, expectedList) return url } var result: WebURL - // Remove from the front. + // Remove non-empty range from the front. result = remove(offsets: 0..<2) XCTAssertEqual(result.serialized(), checkpoints[0]) - // Remove from the middle. + // Remove non-empty range from the middle. result = remove(offsets: 2..<3) XCTAssertEqual(result.serialized(), checkpoints[1]) - // Remove from the end. + // Remove non-empty range from the end. result = remove(offsets: max(initialPairs.count - 2, 0)..( - url urlString: String, component: KeyValuePairsSupportedComponent, schema: Schema, checkpoints: [String] - ) where Schema: KeyValueStringSchema { + func _testRemoveOne( + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema, checkpoints: [String] + ) { let url = WebURL(urlString)! XCTAssertEqual(url.serialized(), urlString) @@ -1389,6 +1461,7 @@ extension KeyValuePairsTests { let initialPairs = url.keyValuePairs(in: component, schema: schema).map { KeyValuePair($0) } func remove(offset: Int) -> WebURL { + var url = url let idx = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in let idx = kvps.remove(at: kvps.index(kvps.startIndex, offsetBy: offset)) @@ -1398,11 +1471,18 @@ extension KeyValuePairsTests { XCTAssertURLIsIdempotent(url) let kvps = url.keyValuePairs(in: component, schema: schema) + + // Check that the returned indexes are at the expected offsets, + // and contain the expected new pairs. + XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: offset), idx) - var expected = initialPairs - expected.remove(at: offset) - XCTAssertEqualKeyValuePairs(kvps, expected) + // Perform the same operation on an Array, + // and check that our operation is consistent with those semantics. + + var expectedList = initialPairs + expectedList.remove(at: offset) + XCTAssertEqualKeyValuePairs(kvps, expectedList) return url } @@ -1422,7 +1502,9 @@ extension KeyValuePairsTests { XCTAssertEqual(result.serialized(), checkpoints[2]) } - // Form-encoding in the query (non-empty query). + // Form-encoding in the query. + // Initial query = non-empty list of pairs. + _testRemoveOne( url: "http://example/?first=0&second=1&third=2&fourth=3#frag", component: .query, schema: .formEncoded, checkpoints: [ @@ -1432,7 +1514,9 @@ extension KeyValuePairsTests { ] ) - // Form-encoding in the query (extra empty pairs). + // Form-encoding in the query (2). + // Initial query = non-empty list of pairs. Component includes empty pairs. + _testRemoveOne( url: "http://example/?&&&first=0&&&second=1&&&third=2&&&fourth=3&&&#frag", component: .query, schema: .formEncoded, checkpoints: [ @@ -1443,6 +1527,7 @@ extension KeyValuePairsTests { ) // Custom schema in the fragment. + _testRemoveOne( url: "http://example/?srch#first:0,second:1,third:2,fourth:3", component: .fragment, schema: CommaSeparated(), checkpoints: [ @@ -1454,156 +1539,181 @@ extension KeyValuePairsTests { } } -// removeAll(where:) +// removeAll(in:where:) extension KeyValuePairsTests { + /// Tests using `removeAll(in:where:)` to remove key-value pairs in a given range which match a predicate. + /// + /// Tests a variety of empty and non-empty ranges at various points within the list, + /// including full- and single-element ranges, with always-true and always-false predicates. + /// Also tests some more interesting predicates which depend on the decoded content of the key-value pairs. + /// + /// Finally, tests that the predicate visits only elements within the given range, and in the correct order. + /// + /// **Tests are repeated**: + /// + /// 1. In various URL components, + /// 2. Using a variety of schemas with different escaping rules and delimiters, + /// 3. With empty pairs inserted at various points in the URL component, and + /// 4. With over-encoded content. + /// func testRemoveAllWhere() { func _testRemoveWhereElement( - url urlString: String, component: KeyValuePairsSupportedComponent, schema: Schema, checkpoints: [String] + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: Schema, checkpoints: [String] ) where Schema: KeyValueStringSchema { let url = WebURL(urlString)! XCTAssertEqual(url.serialized(), urlString) - let oldKVPs = url.keyValuePairs(in: component, schema: schema).map { KeyValuePair($0) } - let count = oldKVPs.count + let initialPairs = url.keyValuePairs(in: component, schema: schema).map { KeyValuePair($0) } + let initialCount = initialPairs.count + precondition(initialCount >= 4, "Minimum 4 pairs needed for this test") + + func remove(in range: Range, where predicate: (WebURL.KeyValuePairs.Element) -> Bool) -> WebURL { - func remove( - in offset: Range, - where predicate: (WebURL.KeyValuePairs.Element) -> Bool - ) -> WebURL { var copy = url copy.withMutableKeyValuePairs(in: component, schema: schema) { kvps in - let lower = kvps.index(kvps.startIndex, offsetBy: offset.lowerBound) - let upper = kvps.index(kvps.startIndex, offsetBy: offset.upperBound) + let lower = kvps.index(kvps.startIndex, offsetBy: range.lowerBound) + let upper = kvps.index(kvps.startIndex, offsetBy: range.upperBound) kvps.removeAll(in: lower..) { + func _checkKVPsVisitedByPredicate(in offsets: Range) { var seen: [KeyValuePair] = [] var copy = url copy.withMutableKeyValuePairs(in: component, schema: schema) { kvps in @@ -1614,127 +1724,137 @@ extension KeyValuePairsTests { return false }) } - let expected = Array(oldKVPs.dropFirst(offsets.lowerBound).prefix(offsets.count)) + let expected = Array(initialPairs.dropFirst(offsets.lowerBound).prefix(offsets.count)) XCTAssertEqual(expected, seen) } - _checkSeenKVPs(in: 0..( - url urlString: String, component: KeyValuePairsSupportedComponent, schema: Schema, checkpoints: [ExpectedResult] - ) where Schema: KeyValueStringSchema { + func _testRemoveCompatibility( + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema, checkpoints: [String] + ) { let url = WebURL(urlString)! XCTAssertEqual(url.serialized(), urlString) - let oldKVPs = url.keyValuePairs(in: component, schema: schema).map { KeyValuePair($0) } - let count = oldKVPs.count + let initialCount = url.keyValuePairs(in: component, schema: schema).count + precondition(initialCount >= 3, "Minimum 3 pairs required for this test") - func removeWhere( - _ offset: Range - ) -> WebURL { + func removeWhere(_ offset: Range) -> WebURL { var copy = url copy.withMutableKeyValuePairs(in: component, schema: schema) { kvps in let lower = kvps.index(kvps.startIndex, offsetBy: offset.lowerBound) @@ -1830,9 +1938,7 @@ extension KeyValuePairsTests { return copy } - func removeSubrange( - _ offset: Range - ) -> WebURL { + func removeSubrange(_ offset: Range) -> WebURL { var copy = url copy.withMutableKeyValuePairs(in: component, schema: schema) { kvps in let lower = kvps.index(kvps.startIndex, offsetBy: offset.lowerBound) @@ -1844,45 +1950,36 @@ extension KeyValuePairsTests { return copy } - var resultRW: WebURL - var resultRS: WebURL - var checkpointIdx = 0 - for i in 0...count { - resultRW = removeWhere(0..( - url urlString: String, component: KeyValuePairsSupportedComponent, schema: Schema, checkpoints: [String] + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: Schema, checkpoints: [String] ) where Schema: KeyValueStringSchema { - var url = WebURL(urlString)! + let url = WebURL(urlString)! XCTAssertEqual(url.serialized(), urlString) - let initialCount = url.keyValuePairs(in: component, schema: schema).count + let initialPairs = url.keyValuePairs(in: component, schema: schema).map { KeyValuePair($0) } + let initialCount = initialPairs.count - do { - let pairsToInsert = [ - // Duplicate keys. - ("foo", "bar"), - ("foo", "baz"), - // Spaces. - ("the key", "sp ace"), - // Empty key/value. - ("", "emptykey"), - ("emptyval", ""), - ("", ""), - ] + func checkAppendSingleOverload( + _ newPairs: Input, + operation: (inout WebURL.KeyValuePairs, Input) -> Range.Index>?, + expectedResult: String, + expectedNewPairs: [KeyValuePair] + ) where Input: Collection { + + var url = url let insertedPairIndexes = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in - let insertedPairIndexes = kvps.append(contentsOf: pairsToInsert) + let insertedPairIndexes = operation(&kvps, newPairs) XCTAssertKeyValuePairCacheIsAccurate(kvps) return insertedPairIndexes } XCTAssertURLIsIdempotent(url) - XCTAssertEqual(url.serialized(), checkpoints[0]) - - let kvps = url.keyValuePairs(in: component, schema: schema) - XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: initialCount), insertedPairIndexes.lowerBound) - XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: initialCount + 6), insertedPairIndexes.upperBound) - XCTAssertEqual(kvps.endIndex, insertedPairIndexes.upperBound) - XCTAssertEqual(kvps.count, initialCount + 6) - - XCTAssertEqualKeyValuePairs(kvps[insertedPairIndexes], pairsToInsert) - } - - do { - let pairsToInsert = [ - // Duplicate pairs. - (key: "foo", value: "bar"), - (key: "", value: ""), - // Unicode, special characters. - (key: "cafe\u{0301}", value: "caf\u{00E9}"), - (key: Self.SpecialCharacters, value: Self.SpecialCharacters), - ] - let insertedPairIndexes = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in - kvps.append(contentsOf: pairsToInsert) - } - XCTAssertURLIsIdempotent(url) - XCTAssertEqual(url.serialized(), checkpoints[1]) + XCTAssertEqual(url.serialized(), expectedResult) let kvps = url.keyValuePairs(in: component, schema: schema) - XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: initialCount + 6), insertedPairIndexes.lowerBound) - XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: initialCount + 10), insertedPairIndexes.upperBound) - XCTAssertEqual(kvps.endIndex, insertedPairIndexes.upperBound) - XCTAssertEqual(kvps.count, initialCount + 10) - XCTAssertEqualKeyValuePairs(kvps[insertedPairIndexes], pairsToInsert) - } + // Check that the returned indexes are at the expected offsets, + // and contain the expected new pairs. - do { - let pairsToInsert = [ - "zip": "zop", - "abc": "def", - "CAT": "x", - ] - let insertedPairIndexes = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in - kvps.append(contentsOf: pairsToInsert) + if let insertedPairIndexes = insertedPairIndexes { + XCTAssertEqual( + kvps.index(kvps.startIndex, offsetBy: initialCount), + insertedPairIndexes.lowerBound + ) + XCTAssertEqual( + kvps.index(kvps.startIndex, offsetBy: initialCount + expectedNewPairs.count), + insertedPairIndexes.upperBound + ) + XCTAssertEqual(kvps.endIndex, insertedPairIndexes.upperBound) + XCTAssertEqualKeyValuePairs(kvps[insertedPairIndexes], expectedNewPairs) } - XCTAssertURLIsIdempotent(url) - XCTAssertEqual(url.serialized(), checkpoints[2]) - let kvps = url.keyValuePairs(in: component, schema: schema) - XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: initialCount + 10), insertedPairIndexes.lowerBound) - XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: initialCount + 13), insertedPairIndexes.upperBound) - XCTAssertEqual(kvps.endIndex, insertedPairIndexes.upperBound) - XCTAssertEqual(kvps.count, initialCount + 13) - - XCTAssertEqualKeyValuePairs(kvps[insertedPairIndexes], pairsToInsert.sorted(by: { $0.key < $1.key })) - } - - XCTAssertEqualKeyValuePairs(url.keyValuePairs(in: component, schema: schema).dropFirst(initialCount), [ - (key: "foo", value: "bar"), - (key: "foo", value: "baz"), - (key: "the key", value: "sp ace"), - (key: "", value: "emptykey"), - (key: "emptyval", value: ""), - (key: "", value: ""), - (key: "foo", value: "bar"), - (key: "", value: ""), - (key: "cafe\u{0301}", value: "caf\u{00E9}"), - (key: Self.SpecialCharacters, value: Self.SpecialCharacters), - (key: "CAT", value: "x"), - (key: "abc", value: "def"), - (key: "zip", value: "zop"), - ]) + // Perform the same operation on an Array, + // and check that our operation is consistent with those semantics. + + var expectedList = initialPairs + expectedList.append(contentsOf: expectedNewPairs) + XCTAssertEqualKeyValuePairs(kvps, expectedList) + } + + func checkAppendAllOverloads(_ newPairs: [(String, String)], checkpointIndex: inout Int) { + + let expectedNewPairs_Tuple = newPairs.map { KeyValuePair(key: $0.0, value: $0.1) } + + // Overload: Unlabelled Tuples. + + checkAppendSingleOverload( + newPairs, + operation: { kvps, pairs in kvps.append(contentsOf: pairs) }, + expectedResult: checkpoints[checkpointIndex], + expectedNewPairs: expectedNewPairs_Tuple + ) + + checkAppendSingleOverload( + newPairs, + operation: { kvps, pairs in + kvps += pairs + return nil + }, + expectedResult: checkpoints[checkpointIndex], + expectedNewPairs: expectedNewPairs_Tuple + ) + + // Overload: Labelled Tuples. + // Don't use Array because it has special magic which allows implicit stripping/inserting of tuple labels. + + checkAppendSingleOverload( + newPairs.lazy.map { (key: $0.0, value: $0.1) }, + operation: { kvps, pairs in kvps.append(contentsOf: pairs) }, + expectedResult: checkpoints[checkpointIndex], + expectedNewPairs: expectedNewPairs_Tuple + ) + + checkAppendSingleOverload( + newPairs.lazy.map { (key: $0.0, value: $0.1) }, + operation: { kvps, pairs in + kvps += pairs + return nil + }, + expectedResult: checkpoints[checkpointIndex], + expectedNewPairs: expectedNewPairs_Tuple + ) + + // Overload: Dictionary. + // If there are duplicate key-value pairs, the first one is kept. + + let dict = Dictionary(newPairs, uniquingKeysWith: { first, _ in first }) + let expectedNewPairs_dict = dict.sorted { $0.key < $1.key }.map { KeyValuePair(key: $0.key, value: $0.value) } + + checkAppendSingleOverload( + dict, + operation: { kvps, pairs in kvps.append(contentsOf: pairs) }, + expectedResult: checkpoints[checkpointIndex + 1], + expectedNewPairs: expectedNewPairs_dict + ) + + checkAppendSingleOverload( + dict, + operation: { kvps, pairs in + kvps += pairs + return nil + }, + expectedResult: checkpoints[checkpointIndex + 1], + expectedNewPairs: expectedNewPairs_dict + ) + + checkpointIndex += 2 + } + + var checkpointIndex = 0 + + // Empty collection. + checkAppendAllOverloads( + [], + checkpointIndex: &checkpointIndex + ) + + // Non-empty collection. + checkAppendAllOverloads( + [ + ("foo", "bar"), + ("foo", "baz"), + ("the key", "sp ace"), + ("", "emptykey"), + ("emptyval", ""), + ("", ""), + ("cafe\u{0301}", "caf\u{00E9}"), + (Self.SpecialCharacters, Self.SpecialCharacters), + ("CAT", "x"), + ], + checkpointIndex: &checkpointIndex + ) } - // Form encoded in the query (starts with nil query). + // Form encoded in the query. + // Initial query = nil. + _testAppendCollection( url: "http://example/#frag", component: .query, schema: .formEncoded, checkpoints: [ - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=#frag", - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=&foo=bar&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)#frag", - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=&foo=bar&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x&abc=def&zip=zop#frag" + "http://example/#frag", + "http://example/#frag", + + "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x#frag", + "http://example/?=emptykey&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x&cafe%CC%81=caf%C3%A9&emptyval=&foo=bar&the%20key=sp%20ace#frag", ] ) - // Form encoded in the query (starts with empty query, encodeSpaceAsPlus = true). + // Form encoded in the query (2). + // Initial query = empty string. Inserted spaces are encoded using "+" signs. + _testAppendCollection( url: "http://example/?#frag", component: .query, schema: ExtendedForm(encodeSpaceAsPlus: true), checkpoints: [ - "http://example/?foo=bar&foo=baz&the+key=sp+ace&=emptykey&emptyval=&=#frag", - "http://example/?foo=bar&foo=baz&the+key=sp+ace&=emptykey&emptyval=&=&foo=bar&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form_Plus)=\(Self.SpecialCharacters_Escaped_Form_Plus)#frag", - "http://example/?foo=bar&foo=baz&the+key=sp+ace&=emptykey&emptyval=&=&foo=bar&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form_Plus)=\(Self.SpecialCharacters_Escaped_Form_Plus)&CAT=x&abc=def&zip=zop#frag" + "http://example/#frag", + "http://example/#frag", + + "http://example/?foo=bar&foo=baz&the+key=sp+ace&=emptykey&emptyval=&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form_Plus)=\(Self.SpecialCharacters_Escaped_Form_Plus)&CAT=x#frag", + "http://example/?=emptykey&\(Self.SpecialCharacters_Escaped_Form_Plus)=\(Self.SpecialCharacters_Escaped_Form_Plus)&CAT=x&cafe%CC%81=caf%C3%A9&emptyval=&foo=bar&the+key=sp+ace#frag", ] ) - // Form encoded in the query (starts with non-empty query; existing content must be preserved unchanged). + // Form encoded in the query (3). + // Initial query = non-empty list of pairs. Component includes empty pairs and special characters. + _testAppendCollection( - url: "http://example/?&test[+x%?]~=val^_`&&&x::y#frag", + url: "http://example/?&test[+x%?]~=val^_`&&&x:y#frag", component: .query, schema: .formEncoded, checkpoints: [ - "http://example/?&test[+x%?]~=val^_`&&&x::y&foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=#frag", - "http://example/?&test[+x%?]~=val^_`&&&x::y&foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=&foo=bar&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)#frag", - "http://example/?&test[+x%?]~=val^_`&&&x::y&foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=&foo=bar&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x&abc=def&zip=zop#frag" - ] - ) + "http://example/?&test[+x%?]~=val^_`&&&x:y#frag", + "http://example/?&test[+x%?]~=val^_`&&&x:y#frag", - // Percent encoded in the query (starts with nil query, minimal percent-encoding). - _testAppendCollection( - url: "http://example/#frag", - component: .query, schema: .percentEncoded, checkpoints: [ - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=#frag", - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=&foo=bar&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_MinPctEnc_Query)=\(Self.SpecialCharacters_Escaped_MinPctEnc_Query)#frag", - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=&foo=bar&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_MinPctEnc_Query)=\(Self.SpecialCharacters_Escaped_MinPctEnc_Query)&CAT=x&abc=def&zip=zop#frag" + "http://example/?&test[+x%?]~=val^_`&&&x:y&foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x#frag", + "http://example/?&test[+x%?]~=val^_`&&&x:y&=emptykey&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x&cafe%CC%81=caf%C3%A9&emptyval=&foo=bar&the%20key=sp%20ace#frag", ] ) - // Custom schema in the fragment (starts with nil fragment, minimal percent-encoding). + // Form encoded in the query (4). + // Initial query = non-empty string, empty list of pairs. + _testAppendCollection( - url: "http://example/?srch", - component: .fragment, schema: CommaSeparated(minimalPercentEncoding: true), checkpoints: [ - "http://example/?srch#foo:bar,foo:baz,the%20key:sp%20ace,:emptykey,emptyval:,:", - "http://example/?srch#foo:bar,foo:baz,the%20key:sp%20ace,:emptykey,emptyval:,:,foo:bar,:,cafe%CC%81:caf%C3%A9,\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag):\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag)", - "http://example/?srch#foo:bar,foo:baz,the%20key:sp%20ace,:emptykey,emptyval:,:,foo:bar,:,cafe%CC%81:caf%C3%A9,\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag):\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag),CAT:x,abc:def,zip:zop" + url: "http://example/?&&&#frag", + component: .query, schema: .formEncoded, checkpoints: [ + "http://example/#frag", + "http://example/#frag", + + "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x#frag", + "http://example/?=emptykey&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x&cafe%CC%81=caf%C3%A9&emptyval=&foo=bar&the%20key=sp%20ace#frag", ] ) - } - func testAppendCollection_emptyCollection() { - - // Appending an empty collection of pairs does not change the contents of the key-value pair list. - // - // However, that does NOT mean it is a no-op - it CAN alter the underlying URL component string, - // even if the list of pairs that are parsed from that string remains unchanged. - // - // In particular, when the list is empty, startIndex == endIndex, and appending effectively becomes - // 'replaceSubrange(startIndex.. "httpx://example/" - // - "httpx://example/?" -> "httpx://example/" - // - "httpx://example/?&&&" -> "httpx://example/" + // Form encoded in the query (5). + // Initial query = non-empty list of pairs. Component ends with a single trailing pair delimiter. + // The single delimiter is re-used when appending new pairs. - func _testAppendEmptyCollection( - url urlString: String, expected: String, component: KeyValuePairsSupportedComponent, schema: Schema - ) { - - let url = WebURL(urlString)! - XCTAssertEqual(url.serialized(), urlString) - - let elementsBefore = url.keyValuePairs(in: component, schema: schema).map { KeyValuePair($0) } - - do { - var url = url - let indexes = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in - let indexes = kvps.append(contentsOf: [] as Array<(String, String)>) - XCTAssertKeyValuePairCacheIsAccurate(kvps) - return indexes - } - XCTAssertURLIsIdempotent(url) - XCTAssertEqual(url.serialized(), expected) - - let kvps = url.keyValuePairs(in: component, schema: schema) - XCTAssertEqualKeyValuePairs(kvps, elementsBefore) - XCTAssertEqual(kvps.endIndex, indexes.lowerBound) - XCTAssertEqual(kvps.endIndex, indexes.upperBound) - } - do { - var url = url - let indexes = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in - let indexes = kvps.append(contentsOf: [] as Array<(key: String, value: String)>) - XCTAssertKeyValuePairCacheIsAccurate(kvps) - return indexes - } - - XCTAssertURLIsIdempotent(url) - XCTAssertEqual(url.serialized(), expected) - - let kvps = url.keyValuePairs(in: component, schema: schema) - XCTAssertEqualKeyValuePairs(kvps, elementsBefore) - XCTAssertEqual(kvps.endIndex, indexes.lowerBound) - XCTAssertEqual(kvps.endIndex, indexes.upperBound) - } - do { - var url = url - let indexes = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in - let indexes = kvps.append(contentsOf: [:] as Dictionary) - XCTAssertKeyValuePairCacheIsAccurate(kvps) - return indexes - } - - XCTAssertURLIsIdempotent(url) - XCTAssertEqual(url.serialized(), expected) - - let kvps = url.keyValuePairs(in: component, schema: schema) - XCTAssertEqualKeyValuePairs(kvps, elementsBefore) - XCTAssertEqual(kvps.endIndex, indexes.lowerBound) - XCTAssertEqual(kvps.endIndex, indexes.upperBound) - } - } - - // Nil query. - _testAppendEmptyCollection( - url: "http://example/#frag", expected: "http://example/#frag", - component: .query, schema: .formEncoded - ) + _testAppendCollection( + url: "http://example/?test=ok&#frag", + component: .query, schema: .formEncoded, checkpoints: [ + "http://example/?test=ok&#frag", + "http://example/?test=ok&#frag", - // Empty query. - _testAppendEmptyCollection( - url: "http://example/?#frag", expected: "http://example/#frag", - component: .query, schema: .formEncoded + "http://example/?test=ok&foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x#frag", + "http://example/?test=ok&=emptykey&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x&cafe%CC%81=caf%C3%A9&emptyval=&foo=bar&the%20key=sp%20ace#frag", + ] ) - // Non-empty query (but empty list). - _testAppendEmptyCollection( - url: "http://example/?&#frag", expected: "http://example/#frag", - component: .query, schema: .formEncoded - ) - _testAppendEmptyCollection( - url: "http://example/?&&&&#frag", expected: "http://example/#frag", - component: .query, schema: .formEncoded - ) - _testAppendEmptyCollection( - url: "http://example/?&;&;#frag", expected: "http://example/#frag", - component: .query, schema: ExtendedForm(semicolonIsPairDelimiter: true) - ) + // Form encoded in the query (6). + // Initial query = non-empty list of pairs. Component ends with a single trailing key-value delimiter. + // Since it is not a pair delimiter, it should not be reused. - // Non-empty query (non-empty list). - _testAppendEmptyCollection( - url: "http://example/?foo=bar&baz=qux#frag", expected: "http://example/?foo=bar&baz=qux#frag", - component: .query, schema: .formEncoded - ) + _testAppendCollection( + url: "http://example/?test=#frag", + component: .query, schema: .formEncoded, checkpoints: [ + "http://example/?test=#frag", + "http://example/?test=#frag", - // Nil fragment. - _testAppendEmptyCollection( - url: "http://example/?srch", expected: "http://example/?srch", - component: .fragment, schema: CommaSeparated() + "http://example/?test=&foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x#frag", + "http://example/?test=&=emptykey&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x&cafe%CC%81=caf%C3%A9&emptyval=&foo=bar&the%20key=sp%20ace#frag", + ] ) - // Empty fragment. - _testAppendEmptyCollection( - url: "http://example/?srch#", expected: "http://example/?srch", - component: .fragment, schema: CommaSeparated() - ) + // Form encoded in the query (7). + // Initial query = non-empty list of pairs. Component ends with multiple trailing delimiters. + // Only one trailing delimiter is re-used when appending new pairs. - // Non-empty fragment (but empty list). - _testAppendEmptyCollection( - url: "http://example/?srch#,,,", expected: "http://example/?srch", - component: .fragment, schema: CommaSeparated() - ) + _testAppendCollection( + url: "http://example/?test=ok&&&#frag", + component: .query, schema: .formEncoded, checkpoints: [ + "http://example/?test=ok&&&#frag", + "http://example/?test=ok&&&#frag", - // Non-empty fragment (non-empty list). - _testAppendEmptyCollection( - url: "http://example/?srch#foo:bar,baz:qux", expected: "http://example/?srch#foo:bar,baz:qux", - component: .fragment, schema: CommaSeparated() + "http://example/?test=ok&&&foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x#frag", + "http://example/?test=ok&&&=emptykey&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)&CAT=x&cafe%CC%81=caf%C3%A9&emptyval=&foo=bar&the%20key=sp%20ace#frag", + ] ) - } - func testAppendCollection_trailingEmptyPairs() { + // Percent encoded in the query. + // Initial query = nil. - let pairsToInsert = [(key: "foo", value: "bar"), (key: "baz", value: "qux")] - - // If the string already ends with a pair delimiter, we won't add another one when appending. + _testAppendCollection( + url: "http://example/#frag", + component: .query, schema: .percentEncoded, checkpoints: [ + "http://example/#frag", + "http://example/#frag", - do { - var url = WebURL("http://example/?test=ok&")! - let indexesOfInserted = url.queryParams.append(contentsOf: pairsToInsert) - XCTAssertEqual(url.serialized(), "http://example/?test=ok&foo=bar&baz=qux") - - let kvps = url.queryParams - XCTAssertEqualKeyValuePairs(kvps[indexesOfInserted], pairsToInsert) - XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: 1), indexesOfInserted.lowerBound) - XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: 3), indexesOfInserted.upperBound) - XCTAssertEqual(kvps.endIndex, indexesOfInserted.upperBound) - } + "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&=emptykey&emptyval=&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_PrctEnc_Query)=\(Self.SpecialCharacters_Escaped_PrctEnc_Query)&CAT=x#frag", + "http://example/?=emptykey&\(Self.SpecialCharacters_Escaped_PrctEnc_Query)=\(Self.SpecialCharacters_Escaped_PrctEnc_Query)&CAT=x&cafe%CC%81=caf%C3%A9&emptyval=&foo=bar&the%20key=sp%20ace#frag", + ] + ) - // In general, appending an element won't delete all trailing pair delimiters, though. + // Custom schema in the fragment. - for n in 1...5 { - var url = WebURL("http://example/?test=ok\(String(repeating: "&", count: n))")! - let indexesOfInserted = url.queryParams.append(contentsOf: pairsToInsert) - XCTAssertEqual(url.serialized(), "http://example/?test=ok\(String(repeating: "&", count: n))foo=bar&baz=qux") + _testAppendCollection( + url: "http://example/?srch", + component: .fragment, schema: CommaSeparated(), checkpoints: [ + "http://example/?srch", + "http://example/?srch", - let kvps = url.queryParams - XCTAssertEqualKeyValuePairs(kvps[indexesOfInserted], pairsToInsert) - XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: 1), indexesOfInserted.lowerBound) - XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: 3), indexesOfInserted.upperBound) - XCTAssertEqual(kvps.endIndex, indexesOfInserted.upperBound) - } + "http://example/?srch#foo:bar,foo:baz,the%20key:sp%20ace,:emptykey,emptyval:,:,cafe%CC%81:caf%C3%A9,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):\(Self.SpecialCharacters_Escaped_CommaSep_Frag),CAT:x", + "http://example/?srch#:emptykey,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):\(Self.SpecialCharacters_Escaped_CommaSep_Frag),CAT:x,cafe%CC%81:caf%C3%A9,emptyval:,foo:bar,the%20key:sp%20ace", + ] + ) - // The exception is when the append location is startIndex (i.e. appending to an empty list). - // When modifying, startIndex snaps back to the start of the URL component, eating any delimiters - // that precede the first non-empty key-value pair. + // Custom schema in the fragment (2). + // Component ends with single trailing delimiter. - for i in 0...5 { - var url = WebURL("http://example/?\(String(repeating: "&", count: i))")! - let indexesOfInserted = url.queryParams.append(contentsOf: pairsToInsert) - XCTAssertEqual(url.serialized(), "http://example/?foo=bar&baz=qux") + _testAppendCollection( + url: "http://example/?srch#test:ok,", + component: .fragment, schema: CommaSeparated(), checkpoints: [ + "http://example/?srch#test:ok,", + "http://example/?srch#test:ok,", - let kvps = url.queryParams - XCTAssertEqualKeyValuePairs(kvps[indexesOfInserted], pairsToInsert) - XCTAssertEqual(kvps.startIndex, indexesOfInserted.lowerBound) - XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: 2), indexesOfInserted.upperBound) - XCTAssertEqual(kvps.endIndex, indexesOfInserted.upperBound) - } + "http://example/?srch#test:ok,foo:bar,foo:baz,the%20key:sp%20ace,:emptykey,emptyval:,:,cafe%CC%81:caf%C3%A9,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):\(Self.SpecialCharacters_Escaped_CommaSep_Frag),CAT:x", + "http://example/?srch#test:ok,:emptykey,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):\(Self.SpecialCharacters_Escaped_CommaSep_Frag),CAT:x,cafe%CC%81:caf%C3%A9,emptyval:,foo:bar,the%20key:sp%20ace", + ] + ) } + /// Tests using `append(key:value:)` to append a single key-value pair to the list. + /// + /// The appended pair will have interesting features, such as special characters, + /// empty key name or value, Unicode, etc. + /// + /// **Tests are repeated**: + /// + /// 1. In various URL components, + /// 2. Using a variety of schemas with different escaping rules and delimiters, + /// 3. With empty pairs inserted at various points in the URL component, and + /// 4. With one or more trailing delimiters. + /// func testAppendOne() { - func _testAppendOne( + func _testAppendOne( url urlString: String, - component: KeyValuePairsSupportedComponent, schema: Schema, checkpoints: [String] - ) where Schema: KeyValueStringSchema { + component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema, checkpoints: [String] + ) { - var url = WebURL(urlString)! + let url = WebURL(urlString)! XCTAssertEqual(url.serialized(), urlString) - let initialCount = url.keyValuePairs(in: component, schema: schema).count - var expectedCount = initialCount + let initialPairs = url.keyValuePairs(in: component, schema: schema).map { KeyValuePair($0) } + let initialCount = initialPairs.count - func append(key: String, value: String, checkpointIdx: Int) { + func append(key: String, value: String) -> WebURL { + var url = url let appendedPairIndex = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in let appendedPairIndex = kvps.append(key: key, value: value) XCTAssertKeyValuePairCacheIsAccurate(kvps) @@ -2294,202 +2402,209 @@ extension KeyValuePairsTests { XCTAssertURLIsIdempotent(url) let kvps = url.keyValuePairs(in: component, schema: schema) - XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: expectedCount), appendedPairIndex) - XCTAssertEqual(KeyValuePair(kvps[appendedPairIndex]), KeyValuePair(key: key, value: value)) + let expectedPair = KeyValuePair(key: key, value: value) - expectedCount += 1 - XCTAssertEqual(kvps.count, expectedCount) + // Check that the returned index is at the expected offset, + // and contains the expected new pair. - XCTAssertEqual(url.serialized(), checkpoints[checkpointIdx]) - } + XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: initialCount), appendedPairIndex) + XCTAssertEqual(KeyValuePair(kvps[appendedPairIndex]), expectedPair) - // Append a single key. - append(key: "foo", value: "bar", checkpointIdx: 0) + // Perform the same operation on an Array, + // and check that our operation is consistent with those semantics. - // Same key, different value. - append(key: "foo", value: "baz", checkpointIdx: 1) + var expectedList = initialPairs + expectedList.append(expectedPair) + XCTAssertEqualKeyValuePairs(kvps, expectedList) - // Key/Value has a space. - append(key: "the key", value: "sp ace", checkpointIdx: 2) + return url + } - // Another duplicate - same key, same value. - append(key: "foo", value: "bar", checkpointIdx: 3) + var result: WebURL - // Empty key. - append(key: "", value: "emptykey", checkpointIdx: 4) + result = append(key: "foo", value: "bar") + XCTAssertEqual(result.serialized(), checkpoints[0]) - // Empty value. - append(key: "emptyval", value: "", checkpointIdx: 5) + result = append(key: "the key", value: "sp ace") + XCTAssertEqual(result.serialized(), checkpoints[1]) - // Empty key and value. - append(key: "", value: "", checkpointIdx: 6) + result = append(key: "", value: "emptykey") + XCTAssertEqual(result.serialized(), checkpoints[2]) - // Empty key and value (again). - append(key: "", value: "", checkpointIdx: 7) + result = append(key: "emptyval", value: "") + XCTAssertEqual(result.serialized(), checkpoints[3]) - // Unicode with combining marks. Code-points should be encoded as given. - append(key: "cafe\u{0301}", value: "caf\u{00E9}", checkpointIdx: 8) + result = append(key: "", value: "") + XCTAssertEqual(result.serialized(), checkpoints[4]) - // Special characters. - append(key: Self.SpecialCharacters, value: Self.SpecialCharacters, checkpointIdx: 9) + result = append(key: "cafe\u{0301}", value: "caf\u{00E9}") + XCTAssertEqual(result.serialized(), checkpoints[5]) - // Check the contents. - XCTAssertEqualKeyValuePairs(url.keyValuePairs(in: component, schema: schema).dropFirst(initialCount), [ - (key: "foo", value: "bar"), - (key: "foo", value: "baz"), - (key: "the key", value: "sp ace"), - (key: "foo", value: "bar"), - (key: "", value: "emptykey"), - (key: "emptyval", value: ""), - (key: "", value: ""), - (key: "", value: ""), - (key: "cafe\u{0301}", value: "caf\u{00E9}"), - (key: Self.SpecialCharacters, value: Self.SpecialCharacters), - ]) + result = append(key: Self.SpecialCharacters, value: Self.SpecialCharacters) + XCTAssertEqual(result.serialized(), checkpoints[6]) } - // Form encoded in the query (starts with nil query). + // Form encoded in the query. + // Initial query = nil. + _testAppendOne( url: "http://example/#frag", component: .query, schema: .formEncoded, checkpoints: [ "http://example/?foo=bar#frag", - "http://example/?foo=bar&foo=baz#frag", - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace#frag", - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&foo=bar#frag", - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&foo=bar&=emptykey#frag", - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&foo=bar&=emptykey&emptyval=#frag", - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&foo=bar&=emptykey&emptyval=&=#frag", - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&foo=bar&=emptykey&emptyval=&=&=#frag", - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&foo=bar&=emptykey&emptyval=&=&=&cafe%CC%81=caf%C3%A9#frag", - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&foo=bar&=emptykey&emptyval=&=&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)#frag" + "http://example/?the%20key=sp%20ace#frag", + "http://example/?=emptykey#frag", + "http://example/?emptyval=#frag", + "http://example/?=#frag", + "http://example/?cafe%CC%81=caf%C3%A9#frag", + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)#frag", + ] + ) + + // Form encoded in the query (2). + // Initial query = empty string. Inserted spaces are encoded using "+" signs. + + _testAppendOne( + url: "http://example/?#frag", + component: .query, schema: ExtendedForm(encodeSpaceAsPlus: true), checkpoints: [ + "http://example/?foo=bar#frag", + "http://example/?the+key=sp+ace#frag", + "http://example/?=emptykey#frag", + "http://example/?emptyval=#frag", + "http://example/?=#frag", + "http://example/?cafe%CC%81=caf%C3%A9#frag", + "http://example/?\(Self.SpecialCharacters_Escaped_Form_Plus)=\(Self.SpecialCharacters_Escaped_Form_Plus)#frag", + ] + ) + + // Form encoded in the query (3). + // Initial query = non-empty list of pairs. Component includes empty pairs and special characters. + + _testAppendOne( + url: "http://example/?&test[+x%?]~=val^_`&&&x:y#frag", + component: .query, schema: .formEncoded, checkpoints: [ + "http://example/?&test[+x%?]~=val^_`&&&x:y&foo=bar#frag", + "http://example/?&test[+x%?]~=val^_`&&&x:y&the%20key=sp%20ace#frag", + "http://example/?&test[+x%?]~=val^_`&&&x:y&=emptykey#frag", + "http://example/?&test[+x%?]~=val^_`&&&x:y&emptyval=#frag", + "http://example/?&test[+x%?]~=val^_`&&&x:y&=#frag", + "http://example/?&test[+x%?]~=val^_`&&&x:y&cafe%CC%81=caf%C3%A9#frag", + "http://example/?&test[+x%?]~=val^_`&&&x:y&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)#frag", + ] + ) + + // Form encoded in the query (4). + // Initial query = non-empty string, empty list of pairs. + + _testAppendOne( + url: "http://example/?&&&#frag", + component: .query, schema: .formEncoded, checkpoints: [ + "http://example/?foo=bar#frag", + "http://example/?the%20key=sp%20ace#frag", + "http://example/?=emptykey#frag", + "http://example/?emptyval=#frag", + "http://example/?=#frag", + "http://example/?cafe%CC%81=caf%C3%A9#frag", + "http://example/?\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)#frag", + ] + ) + + // Form encoded in the query (5). + // Initial query = non-empty list of pairs. Component ends with a single trailing pair delimiter. + // The single delimiter is re-used when appending new pairs. + + _testAppendOne( + url: "http://example/?test=ok&#frag", + component: .query, schema: .formEncoded, checkpoints: [ + "http://example/?test=ok&foo=bar#frag", + "http://example/?test=ok&the%20key=sp%20ace#frag", + "http://example/?test=ok&=emptykey#frag", + "http://example/?test=ok&emptyval=#frag", + "http://example/?test=ok&=#frag", + "http://example/?test=ok&cafe%CC%81=caf%C3%A9#frag", + "http://example/?test=ok&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)#frag", ] ) - // Form encoded in the query (starts with empty query, encodeSpaceAsPlus = true). + // Form encoded in the query (6). + // Initial query = non-empty list of pairs. Component ends with a single trailing key-value delimiter. + // Since it is not a pair delimiter, it should not be reused. + _testAppendOne( - url: "http://example/?#frag", - component: .query, schema: ExtendedForm(encodeSpaceAsPlus: true), checkpoints: [ - "http://example/?foo=bar#frag", - "http://example/?foo=bar&foo=baz#frag", - "http://example/?foo=bar&foo=baz&the+key=sp+ace#frag", - "http://example/?foo=bar&foo=baz&the+key=sp+ace&foo=bar#frag", - "http://example/?foo=bar&foo=baz&the+key=sp+ace&foo=bar&=emptykey#frag", - "http://example/?foo=bar&foo=baz&the+key=sp+ace&foo=bar&=emptykey&emptyval=#frag", - "http://example/?foo=bar&foo=baz&the+key=sp+ace&foo=bar&=emptykey&emptyval=&=#frag", - "http://example/?foo=bar&foo=baz&the+key=sp+ace&foo=bar&=emptykey&emptyval=&=&=#frag", - "http://example/?foo=bar&foo=baz&the+key=sp+ace&foo=bar&=emptykey&emptyval=&=&=&cafe%CC%81=caf%C3%A9#frag", - "http://example/?foo=bar&foo=baz&the+key=sp+ace&foo=bar&=emptykey&emptyval=&=&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form_Plus)=\(Self.SpecialCharacters_Escaped_Form_Plus)#frag" + url: "http://example/?test=#frag", + component: .query, schema: .formEncoded, checkpoints: [ + "http://example/?test=&foo=bar#frag", + "http://example/?test=&the%20key=sp%20ace#frag", + "http://example/?test=&=emptykey#frag", + "http://example/?test=&emptyval=#frag", + "http://example/?test=&=#frag", + "http://example/?test=&cafe%CC%81=caf%C3%A9#frag", + "http://example/?test=&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)#frag", ] ) - // Form encoded in the query (starts with non-empty query; existing content must be preserved unchanged). + // Form encoded in the query (7). + // Initial query = non-empty list of pairs. Component ends with multiple trailing delimiters. + // Only one trailing delimiter is re-used when appending new pairs. + _testAppendOne( - url: "http://example/?test[+x%?]~=val^_`&&&x::y#frag", + url: "http://example/?test=ok&&&#frag", component: .query, schema: .formEncoded, checkpoints: [ - "http://example/?test[+x%?]~=val^_`&&&x::y&foo=bar#frag", - "http://example/?test[+x%?]~=val^_`&&&x::y&foo=bar&foo=baz#frag", - "http://example/?test[+x%?]~=val^_`&&&x::y&foo=bar&foo=baz&the%20key=sp%20ace#frag", - "http://example/?test[+x%?]~=val^_`&&&x::y&foo=bar&foo=baz&the%20key=sp%20ace&foo=bar#frag", - "http://example/?test[+x%?]~=val^_`&&&x::y&foo=bar&foo=baz&the%20key=sp%20ace&foo=bar&=emptykey#frag", - "http://example/?test[+x%?]~=val^_`&&&x::y&foo=bar&foo=baz&the%20key=sp%20ace&foo=bar&=emptykey&emptyval=#frag", - "http://example/?test[+x%?]~=val^_`&&&x::y&foo=bar&foo=baz&the%20key=sp%20ace&foo=bar&=emptykey&emptyval=&=#frag", - "http://example/?test[+x%?]~=val^_`&&&x::y&foo=bar&foo=baz&the%20key=sp%20ace&foo=bar&=emptykey&emptyval=&=&=#frag", - "http://example/?test[+x%?]~=val^_`&&&x::y&foo=bar&foo=baz&the%20key=sp%20ace&foo=bar&=emptykey&emptyval=&=&=&cafe%CC%81=caf%C3%A9#frag", - "http://example/?test[+x%?]~=val^_`&&&x::y&foo=bar&foo=baz&the%20key=sp%20ace&foo=bar&=emptykey&emptyval=&=&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)#frag" + "http://example/?test=ok&&&foo=bar#frag", + "http://example/?test=ok&&&the%20key=sp%20ace#frag", + "http://example/?test=ok&&&=emptykey#frag", + "http://example/?test=ok&&&emptyval=#frag", + "http://example/?test=ok&&&=#frag", + "http://example/?test=ok&&&cafe%CC%81=caf%C3%A9#frag", + "http://example/?test=ok&&&\(Self.SpecialCharacters_Escaped_Form)=\(Self.SpecialCharacters_Escaped_Form)#frag", ] ) - // Percent encoded in the query (starts with nil query, minimal percent-encoding). + // Percent encoded in the query. + // Initial query = nil. + _testAppendOne( url: "http://example/#frag", component: .query, schema: .percentEncoded, checkpoints: [ "http://example/?foo=bar#frag", - "http://example/?foo=bar&foo=baz#frag", - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace#frag", - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&foo=bar#frag", - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&foo=bar&=emptykey#frag", - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&foo=bar&=emptykey&emptyval=#frag", - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&foo=bar&=emptykey&emptyval=&=#frag", - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&foo=bar&=emptykey&emptyval=&=&=#frag", - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&foo=bar&=emptykey&emptyval=&=&=&cafe%CC%81=caf%C3%A9#frag", - "http://example/?foo=bar&foo=baz&the%20key=sp%20ace&foo=bar&=emptykey&emptyval=&=&=&cafe%CC%81=caf%C3%A9&\(Self.SpecialCharacters_Escaped_MinPctEnc_Query)=\(Self.SpecialCharacters_Escaped_MinPctEnc_Query)#frag" + "http://example/?the%20key=sp%20ace#frag", + "http://example/?=emptykey#frag", + "http://example/?emptyval=#frag", + "http://example/?=#frag", + "http://example/?cafe%CC%81=caf%C3%A9#frag", + "http://example/?\(Self.SpecialCharacters_Escaped_PrctEnc_Query)=\(Self.SpecialCharacters_Escaped_PrctEnc_Query)#frag", ] ) - // Custom schema in the fragment (starts with nil fragment, minimal percent-encoding). + // Custom schema in the fragment. + _testAppendOne( url: "http://example/?srch", - component: .fragment, schema: CommaSeparated(minimalPercentEncoding: true), checkpoints: [ + component: .fragment, schema: CommaSeparated(), checkpoints: [ "http://example/?srch#foo:bar", - "http://example/?srch#foo:bar,foo:baz", - "http://example/?srch#foo:bar,foo:baz,the%20key:sp%20ace", - "http://example/?srch#foo:bar,foo:baz,the%20key:sp%20ace,foo:bar", - "http://example/?srch#foo:bar,foo:baz,the%20key:sp%20ace,foo:bar,:emptykey", - "http://example/?srch#foo:bar,foo:baz,the%20key:sp%20ace,foo:bar,:emptykey,emptyval:", - "http://example/?srch#foo:bar,foo:baz,the%20key:sp%20ace,foo:bar,:emptykey,emptyval:,:", - "http://example/?srch#foo:bar,foo:baz,the%20key:sp%20ace,foo:bar,:emptykey,emptyval:,:,:", - "http://example/?srch#foo:bar,foo:baz,the%20key:sp%20ace,foo:bar,:emptykey,emptyval:,:,:,cafe%CC%81:caf%C3%A9", - "http://example/?srch#foo:bar,foo:baz,the%20key:sp%20ace,foo:bar,:emptykey,emptyval:,:,:,cafe%CC%81:caf%C3%A9,\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag):\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag)" + "http://example/?srch#the%20key:sp%20ace", + "http://example/?srch#:emptykey", + "http://example/?srch#emptyval:", + "http://example/?srch#:", + "http://example/?srch#cafe%CC%81:caf%C3%A9", + "http://example/?srch#\(Self.SpecialCharacters_Escaped_CommaSep_Frag):\(Self.SpecialCharacters_Escaped_CommaSep_Frag)", ] ) - } - - func testAppendOne_trailingEmptyPairs() { - - let pairToInsert = (key: "foo", value: "bar") - - // If the string already ends with a pair delimiter, we won't add another one when appending. - - do { - var url = WebURL("http://example/?test=ok&")! - let appendedPairIndex = url.withMutableKeyValuePairs(in: .query, schema: .formEncoded) { kvps in - let appendedPairIndex = kvps.append(key: pairToInsert.key, value: pairToInsert.value) - XCTAssertKeyValuePairCacheIsAccurate(kvps) - return appendedPairIndex - } - XCTAssertEqual(url.serialized(), "http://example/?test=ok&foo=bar") - - let kvps = url.queryParams - XCTAssertEqual(KeyValuePair(kvps[appendedPairIndex]), KeyValuePair(pairToInsert)) - XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: 1), appendedPairIndex) - XCTAssertEqual(kvps.index(appendedPairIndex, offsetBy: 1), kvps.endIndex) - } - - // In general, appending an element won't delete all trailing pair delimiters, though. - - for n in 1...5 { - var url = WebURL("http://example/?test=ok\(String(repeating: "&", count: n))")! - let appendedPairIndex = url.withMutableKeyValuePairs(in: .query, schema: .formEncoded) { kvps in - let appendedPairIndex = kvps.append(key: pairToInsert.key, value: pairToInsert.value) - XCTAssertKeyValuePairCacheIsAccurate(kvps) - return appendedPairIndex - } - XCTAssertEqual(url.serialized(), "http://example/?test=ok\(String(repeating: "&", count: n))foo=bar") - - let kvps = url.queryParams - XCTAssertEqual(KeyValuePair(kvps[appendedPairIndex]), KeyValuePair(pairToInsert)) - XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: 1), appendedPairIndex) - XCTAssertEqual(kvps.index(appendedPairIndex, offsetBy: 1), kvps.endIndex) - } - // The exception is when the append location is startIndex (i.e. appending to an empty list). - // When modifying, startIndex snaps back to the start of the URL component, eating any delimiters - // that precede the first non-empty key-value pair. + // Custom schema in the fragment (2). + // Component ends with single trailing delimiter. - for i in 0...5 { - var url = WebURL("http://example/?\(String(repeating: "&", count: i))")! - let appendedPairIndex = url.withMutableKeyValuePairs(in: .query, schema: .formEncoded) { kvps in - let appendedPairIndex = kvps.append(key: pairToInsert.key, value: pairToInsert.value) - XCTAssertKeyValuePairCacheIsAccurate(kvps) - return appendedPairIndex - } - XCTAssertEqual(url.serialized(), "http://example/?foo=bar") - - let kvps = url.queryParams - XCTAssertEqual(KeyValuePair(kvps[appendedPairIndex]), KeyValuePair(pairToInsert)) - XCTAssertEqual(kvps.startIndex, appendedPairIndex) - XCTAssertEqual(kvps.index(appendedPairIndex, offsetBy: 1), kvps.endIndex) - } + _testAppendOne( + url: "http://example/?srch#test:ok,", + component: .fragment, schema: CommaSeparated(), checkpoints: [ + "http://example/?srch#test:ok,foo:bar", + "http://example/?srch#test:ok,the%20key:sp%20ace", + "http://example/?srch#test:ok,:emptykey", + "http://example/?srch#test:ok,emptyval:", + "http://example/?srch#test:ok,:", + "http://example/?srch#test:ok,cafe%CC%81:caf%C3%A9", + "http://example/?srch#test:ok,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):\(Self.SpecialCharacters_Escaped_CommaSep_Frag)", + ] + ) } } @@ -2497,37 +2612,59 @@ extension KeyValuePairsTests { extension KeyValuePairsTests { + /// Tests using `replaceKey(at:with:)` to replace the key component of a single key-value pair. + /// + /// The new key is either an empty string, or an interesting non-empty string (special characters, Unicode, etc). + /// + /// **Tests are repeated**: + /// + /// 1. In various URL components, + /// 2. Using a variety of schemas with different escaping rules and delimiters, + /// 3. With empty pairs inserted at various points in the URL component, and + /// 4. With pairs containing empty keys and/or values. + /// func testReplaceKeyAt() { - func _testReplaceKeyAt( - url urlString: String, component: KeyValuePairsSupportedComponent, schema: Schema, checkpoints: [String] - ) where Schema: KeyValueStringSchema { + func _testReplaceKeyAt( + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema, checkpoints: [String] + ) { let url = WebURL(urlString)! XCTAssertEqual(url.serialized(), urlString) - let count = url.keyValuePairs(in: component, schema: schema).count + let initialPairs = url.keyValuePairs(in: component, schema: schema).map { KeyValuePair($0) } + let initialCount = initialPairs.count + precondition(initialCount >= 3, "Minimum 3 pairs required for this test") func replaceKey(atOffset offset: Int, with newKey: String) -> WebURL { var url = url - - let (expectedValue, returnedIndex) = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in - let idx = kvps.index(kvps.startIndex, offsetBy: offset) - let retVal = (kvps[idx].value, kvps.replaceKey(at: idx, with: newKey)) + let (valueComponent, returnedIndex) = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in + let index = kvps.index(kvps.startIndex, offsetBy: offset) + let oldValue = kvps[index].encodedValue + let result = (kvps[index].value, kvps.replaceKey(at: index, with: newKey)) XCTAssertKeyValuePairCacheIsAccurate(kvps) - return retVal + XCTAssertEqual(kvps[result.1].encodedValue, oldValue) + return result } XCTAssertURLIsIdempotent(url) let kvps = url.keyValuePairs(in: component, schema: schema) - XCTAssertEqual(KeyValuePair(kvps[returnedIndex]), KeyValuePair(key: newKey, value: expectedValue)) + let expectedPair = KeyValuePair(key: newKey, value: valueComponent) + + // Check that the returned index is at the expected offset, + // and contains the expected new pair. + + XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: offset), returnedIndex) + XCTAssertEqual(KeyValuePair(kvps[returnedIndex]), expectedPair) - let calculatedIndex = kvps.index(kvps.startIndex, offsetBy: offset) - XCTAssertEqual(calculatedIndex, returnedIndex) - XCTAssertEqual(KeyValuePair(kvps[calculatedIndex]), KeyValuePair(key: newKey, value: expectedValue)) + // Perform the same operation on an Array, + // and check that our operation is consistent with those semantics. - XCTAssertEqual(kvps.count, count) + var expectedList = initialPairs + expectedList[offset].key = newKey + XCTAssertEqualKeyValuePairs(kvps, expectedList) return url } @@ -2542,113 +2679,173 @@ extension KeyValuePairsTests { result = replaceKey(atOffset: 1, with: Self.SpecialCharacters) XCTAssertEqual(result.serialized(), checkpoints[1]) - // Replace in the middle (empty). - result = replaceKey(atOffset: 1, with: "") + // Replace at the end. + result = replaceKey(atOffset: initialCount - 1, with: "end key") XCTAssertEqual(result.serialized(), checkpoints[2]) - // Replace at the end. - result = replaceKey(atOffset: count - 1, with: "end key") + // Replace at the front (empty). + result = replaceKey(atOffset: 0, with: "") XCTAssertEqual(result.serialized(), checkpoints[3]) + + // Replace in the middle (empty). + result = replaceKey(atOffset: 1, with: "") + XCTAssertEqual(result.serialized(), checkpoints[4]) + + // Replace at the end (empty). + result = replaceKey(atOffset: initialCount - 1, with: "") + XCTAssertEqual(result.serialized(), checkpoints[5]) } - // Form-encoded in the query. + // Form encoded in the query. + _testReplaceKeyAt( url: "http://example/?foo=bar&baz=qux&another=value#frag", component: .query, schema: .formEncoded, checkpoints: [ "http://example/?some%20replacement=bar&baz=qux&another=value#frag", "http://example/?foo=bar&\(Self.SpecialCharacters_Escaped_Form)=qux&another=value#frag", - "http://example/?foo=bar&=qux&another=value#frag", "http://example/?foo=bar&baz=qux&end%20key=value#frag", + + "http://example/?=bar&baz=qux&another=value#frag", + "http://example/?foo=bar&=qux&another=value#frag", + "http://example/?foo=bar&baz=qux&=value#frag", ] ) - // Form-encoded in the query (encodeSpaceAsPlus = true). + // Form encoded in the query (2). + // Inserted spaces are encoded using "+" signs. + _testReplaceKeyAt( url: "http://example/?foo=bar&baz=qux&another=value#frag", component: .query, schema: ExtendedForm(encodeSpaceAsPlus: true), checkpoints: [ "http://example/?some+replacement=bar&baz=qux&another=value#frag", "http://example/?foo=bar&\(Self.SpecialCharacters_Escaped_Form_Plus)=qux&another=value#frag", - "http://example/?foo=bar&=qux&another=value#frag", "http://example/?foo=bar&baz=qux&end+key=value#frag", + + "http://example/?=bar&baz=qux&another=value#frag", + "http://example/?foo=bar&=qux&another=value#frag", + "http://example/?foo=bar&baz=qux&=value#frag", ] ) - // Empty key-value pairs. + // Form encoded in the query (3). + // Component contains empty pairs. Unlike some other APIs, empty pairs are never removed. + + let s = "[+^x%`/?]~" _testReplaceKeyAt( - url: "http://example/?&&&first[+^x%`?]~=0&&&second[+^x%`?]~=1&&&third[+^x%`?]~=2&&&fourth[+^x%`?]~=3&&&#frag", + url: "http://example/?&&&first\(s)=0&&&second\(s)=1&&&third\(s)=2&&&fourth\(s)=3&&&#frag", component: .query, schema: .formEncoded, checkpoints: [ - "http://example/?&&&some%20replacement=0&&&second[+^x%`?]~=1&&&third[+^x%`?]~=2&&&fourth[+^x%`?]~=3&&&#frag", - "http://example/?&&&first[+^x%`?]~=0&&&\(Self.SpecialCharacters_Escaped_Form)=1&&&third[+^x%`?]~=2&&&fourth[+^x%`?]~=3&&&#frag", - "http://example/?&&&first[+^x%`?]~=0&&&=1&&&third[+^x%`?]~=2&&&fourth[+^x%`?]~=3&&&#frag", - "http://example/?&&&first[+^x%`?]~=0&&&second[+^x%`?]~=1&&&third[+^x%`?]~=2&&&end%20key=3&&&#frag", + "http://example/?&&&some%20replacement=0&&&second\(s)=1&&&third\(s)=2&&&fourth\(s)=3&&&#frag", + "http://example/?&&&first\(s)=0&&&\(Self.SpecialCharacters_Escaped_Form)=1&&&third\(s)=2&&&fourth\(s)=3&&&#frag", + "http://example/?&&&first\(s)=0&&&second\(s)=1&&&third\(s)=2&&&end%20key=3&&&#frag", + + "http://example/?&&&=0&&&second\(s)=1&&&third\(s)=2&&&fourth\(s)=3&&&#frag", + "http://example/?&&&first\(s)=0&&&=1&&&third\(s)=2&&&fourth\(s)=3&&&#frag", + "http://example/?&&&first\(s)=0&&&second\(s)=1&&&third\(s)=2&&&=3&&&#frag", ] ) - // Empty keys and values. + // Form encoded in the query (4). + // Pairs have empty keys and values. + _testReplaceKeyAt( url: "http://example/?=&=&=#frag", component: .query, schema: .formEncoded, checkpoints: [ "http://example/?some%20replacement=&=&=#frag", "http://example/?=&\(Self.SpecialCharacters_Escaped_Form)=&=#frag", - "http://example/?=&=&=#frag", "http://example/?=&=&end%20key=#frag", + + "http://example/?=&=&=#frag", + "http://example/?=&=&=#frag", + "http://example/?=&=&=#frag", ] ) - // No key-value delimiters. + // Form encoded in the query (5). + // Pairs do not have any key-value delimiters. + // When setting the key to the empty string, a delimiter must be inserted. + _testReplaceKeyAt( url: "http://example/?foo&baz&another#frag", component: .query, schema: .formEncoded, checkpoints: [ "http://example/?some%20replacement&baz&another#frag", "http://example/?foo&\(Self.SpecialCharacters_Escaped_Form)&another#frag", - "http://example/?foo&=&another#frag", "http://example/?foo&baz&end%20key#frag", + + "http://example/?=&baz&another#frag", + "http://example/?foo&=&another#frag", + "http://example/?foo&baz&=#frag", ] ) // Custom schema in the fragment. + _testReplaceKeyAt( url: "http://example/?srch#foo:bar,baz:qux,another:value", - component: .fragment, schema: CommaSeparated(minimalPercentEncoding: true), checkpoints: [ + component: .fragment, schema: CommaSeparated(), checkpoints: [ "http://example/?srch#some%20replacement:bar,baz:qux,another:value", - "http://example/?srch#foo:bar,\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag):qux,another:value", - "http://example/?srch#foo:bar,:qux,another:value", + "http://example/?srch#foo:bar,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):qux,another:value", "http://example/?srch#foo:bar,baz:qux,end%20key:value", + + "http://example/?srch#:bar,baz:qux,another:value", + "http://example/?srch#foo:bar,:qux,another:value", + "http://example/?srch#foo:bar,baz:qux,:value", ] ) } + /// Tests using `replaceValue(at:with:)` to replace the value component of a single key-value pair. + /// + /// The new value is either an empty string, or an interesting non-empty string (special characters, Unicode, etc). + /// + /// **Tests are repeated**: + /// + /// 1. In various URL components, + /// 2. Using a variety of schemas with different escaping rules and delimiters, + /// 3. With empty pairs inserted at various points in the URL component, and + /// 4. With pairs containing empty keys and/or values. + /// func testReplaceValueAt() { - func _testReplaceValueAt( - url urlString: String, component: KeyValuePairsSupportedComponent, schema: Schema, checkpoints: [String] - ) where Schema: KeyValueStringSchema { + func _testReplaceValueAt( + url urlString: String, + component: KeyValuePairsSupportedComponent, schema: some KeyValueStringSchema, checkpoints: [String] + ) { let url = WebURL(urlString)! XCTAssertEqual(url.serialized(), urlString) - let count = url.keyValuePairs(in: component, schema: schema).count + let initialPairs = url.keyValuePairs(in: component, schema: schema).map { KeyValuePair($0) } + let initialCount = initialPairs.count + precondition(initialCount >= 3, "Minimum 3 pairs required for this test") func replaceValue(atOffset offset: Int, with newValue: String) -> WebURL { var url = url - - let (expectedKey, returnedIndex) = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in - let idx = kvps.index(kvps.startIndex, offsetBy: offset) - let retVal = (kvps[idx].key, kvps.replaceValue(at: idx, with: newValue)) + let (keyComponent, returnedIndex) = url.withMutableKeyValuePairs(in: component, schema: schema) { kvps in + let index = kvps.index(kvps.startIndex, offsetBy: offset) + let oldKey = kvps[index].encodedKey + let result = (kvps[index].key, kvps.replaceValue(at: index, with: newValue)) XCTAssertKeyValuePairCacheIsAccurate(kvps) - return retVal + XCTAssertEqual(kvps[result.1].encodedKey, oldKey) + return result } XCTAssertURLIsIdempotent(url) let kvps = url.keyValuePairs(in: component, schema: schema) - XCTAssertEqual(KeyValuePair(kvps[returnedIndex]), KeyValuePair(key: expectedKey, value: newValue)) + let expectedPair = KeyValuePair(key: keyComponent, value: newValue) + + // Check that the returned index is at the expected offset, + // and contains the expected new pair. - let calculatedIndex = kvps.index(kvps.startIndex, offsetBy: offset) - XCTAssertEqual(calculatedIndex, returnedIndex) - XCTAssertEqual(KeyValuePair(kvps[calculatedIndex]), KeyValuePair(key: expectedKey, value: newValue)) + XCTAssertEqual(kvps.index(kvps.startIndex, offsetBy: offset), returnedIndex) + XCTAssertEqual(KeyValuePair(kvps[returnedIndex]), expectedPair) - XCTAssertEqual(kvps.count, count) + // Perform the same operation on an Array, + // and check that our operation is consistent with those semantics. + + var expectedList = initialPairs + expectedList[offset].value = newValue + XCTAssertEqualKeyValuePairs(kvps, expectedList) return url } @@ -2663,84 +2860,316 @@ extension KeyValuePairsTests { result = replaceValue(atOffset: 1, with: Self.SpecialCharacters) XCTAssertEqual(result.serialized(), checkpoints[1]) - // Replace in the middle (empty). - result = replaceValue(atOffset: 1, with: "") + // Replace at the end. + result = replaceValue(atOffset: initialCount - 1, with: "end value") XCTAssertEqual(result.serialized(), checkpoints[2]) - // Replace at the end. - result = replaceValue(atOffset: count - 1, with: "end value") + // Replace at the front (empty). + result = replaceValue(atOffset: 0, with: "") XCTAssertEqual(result.serialized(), checkpoints[3]) + + // Replace in the middle (empty). + result = replaceValue(atOffset: 1, with: "") + XCTAssertEqual(result.serialized(), checkpoints[4]) + + // Replace at the end (empty). + result = replaceValue(atOffset: initialCount - 1, with: "") + XCTAssertEqual(result.serialized(), checkpoints[5]) } // Form-encoded in the query. + _testReplaceValueAt( url: "http://example/?foo=bar&baz=qux&another=value#frag", component: .query, schema: .formEncoded, checkpoints: [ "http://example/?foo=some%20replacement&baz=qux&another=value#frag", "http://example/?foo=bar&baz=\(Self.SpecialCharacters_Escaped_Form)&another=value#frag", - "http://example/?foo=bar&baz=&another=value#frag", "http://example/?foo=bar&baz=qux&another=end%20value#frag", + + "http://example/?foo=&baz=qux&another=value#frag", + "http://example/?foo=bar&baz=&another=value#frag", + "http://example/?foo=bar&baz=qux&another=#frag", ] ) - // Form-encoded in the query (encodeSpaceAsPlus = true). + // Form encoded in the query (2). + // Inserted spaces are encoded using "+" signs. + _testReplaceValueAt( url: "http://example/?foo=bar&baz=qux&another=value#frag", component: .query, schema: ExtendedForm(encodeSpaceAsPlus: true), checkpoints: [ "http://example/?foo=some+replacement&baz=qux&another=value#frag", "http://example/?foo=bar&baz=\(Self.SpecialCharacters_Escaped_Form_Plus)&another=value#frag", - "http://example/?foo=bar&baz=&another=value#frag", "http://example/?foo=bar&baz=qux&another=end+value#frag", - ] - ) - // Empty key-value pairs. - _testReplaceValueAt( - url: "http://example/?&&&first[+^x%`?]~=0&&&second[+^x%`?]~=1&&&third[+^x%`?]~=2&&&fourth[+^x%`?]~=3&&&#frag", - component: .query, schema: .formEncoded, checkpoints: [ - "http://example/?&&&first[+^x%`?]~=some%20replacement&&&second[+^x%`?]~=1&&&third[+^x%`?]~=2&&&fourth[+^x%`?]~=3&&&#frag", - "http://example/?&&&first[+^x%`?]~=0&&&second[+^x%`?]~=\(Self.SpecialCharacters_Escaped_Form)&&&third[+^x%`?]~=2&&&fourth[+^x%`?]~=3&&&#frag", - "http://example/?&&&first[+^x%`?]~=0&&&second[+^x%`?]~=&&&third[+^x%`?]~=2&&&fourth[+^x%`?]~=3&&&#frag", - "http://example/?&&&first[+^x%`?]~=0&&&second[+^x%`?]~=1&&&third[+^x%`?]~=2&&&fourth[+^x%`?]~=end%20value&&&#frag", + "http://example/?foo=&baz=qux&another=value#frag", + "http://example/?foo=bar&baz=&another=value#frag", + "http://example/?foo=bar&baz=qux&another=#frag", ] ) - // No key-value delimiters. + // Form encoded in the query (3). + // Component contains empty pairs. Unlike some other APIs, empty pairs are never removed. + + let s = "[+^x%`/?]~" _testReplaceValueAt( - url: "http://example/?foo&baz&another#frag", + url: "http://example/?&&&first\(s)=0&&&second\(s)=1&&&third\(s)=2&&&fourth\(s)=3&&&#frag", component: .query, schema: .formEncoded, checkpoints: [ - "http://example/?foo=some%20replacement&baz&another#frag", - "http://example/?foo&baz=\(Self.SpecialCharacters_Escaped_Form)&another#frag", - "http://example/?foo&baz&another#frag", - "http://example/?foo&baz&another=end%20value#frag", + "http://example/?&&&first\(s)=some%20replacement&&&second\(s)=1&&&third\(s)=2&&&fourth\(s)=3&&&#frag", + "http://example/?&&&first\(s)=0&&&second\(s)=\(Self.SpecialCharacters_Escaped_Form)&&&third\(s)=2&&&fourth\(s)=3&&&#frag", + "http://example/?&&&first\(s)=0&&&second\(s)=1&&&third\(s)=2&&&fourth\(s)=end%20value&&&#frag", + + "http://example/?&&&first\(s)=&&&second\(s)=1&&&third\(s)=2&&&fourth\(s)=3&&&#frag", + "http://example/?&&&first\(s)=0&&&second\(s)=&&&third\(s)=2&&&fourth\(s)=3&&&#frag", + "http://example/?&&&first\(s)=0&&&second\(s)=1&&&third\(s)=2&&&fourth\(s)=&&&#frag", ] ) - // Empty keys and values. + // Form encoded in the query (4). + // Pairs have empty keys and values. + _testReplaceValueAt( url: "http://example/?=&=&=#frag", component: .query, schema: .formEncoded, checkpoints: [ "http://example/?=some%20replacement&=&=#frag", "http://example/?=&=\(Self.SpecialCharacters_Escaped_Form)&=#frag", - "http://example/?=&=&=#frag", "http://example/?=&=&=end%20value#frag", + + "http://example/?=&=&=#frag", + "http://example/?=&=&=#frag", + "http://example/?=&=&=#frag", + ] + ) + + // Form encoded in the query (5). + // Pairs do not have any key-value delimiters. + + _testReplaceValueAt( + url: "http://example/?foo&baz&another#frag", + component: .query, schema: .formEncoded, checkpoints: [ + "http://example/?foo=some%20replacement&baz&another#frag", + "http://example/?foo&baz=\(Self.SpecialCharacters_Escaped_Form)&another#frag", + "http://example/?foo&baz&another=end%20value#frag", + + "http://example/?foo&baz&another#frag", + "http://example/?foo&baz&another#frag", + "http://example/?foo&baz&another#frag", ] ) // Custom schema in the fragment. + _testReplaceValueAt( url: "http://example/?srch#foo:bar,baz:qux,another:value", - component: .fragment, schema: CommaSeparated(minimalPercentEncoding: true), checkpoints: [ + component: .fragment, schema: CommaSeparated(), checkpoints: [ "http://example/?srch#foo:some%20replacement,baz:qux,another:value", - "http://example/?srch#foo:bar,baz:\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag),another:value", - "http://example/?srch#foo:bar,baz:,another:value", + "http://example/?srch#foo:bar,baz:\(Self.SpecialCharacters_Escaped_CommaSep_Frag),another:value", "http://example/?srch#foo:bar,baz:qux,another:end%20value", + + "http://example/?srch#foo:,baz:qux,another:value", + "http://example/?srch#foo:bar,baz:,another:value", + "http://example/?srch#foo:bar,baz:qux,another:", ] ) } } +// -------------------------------------------- +// MARK: - Reading: By Key +// -------------------------------------------- + + +extension KeyValuePairsTests { + + /// Tests using the key lookup subscripts to find the value associated with one or more keys. + /// + /// Checks looking up: + /// + /// - Unique keys + /// - Duplicate keys + /// - Not-present keys + /// - Empty keys + /// - Keys which need unescaping, and + /// - Unicode keys + /// + /// With the single and batched lookup subscripts. + /// + /// **Tests are repeated**: + /// + /// 1. In various URL components, and + /// 2. Using a variety of schemas with different escaping rules and delimiters. + /// + func testKeyLookupSubscript() { + + func _testKeyLookupSubscript(_ kvps: WebURL.KeyValuePairs) { + + // Check the overall list of pairs is what we expect. + + let expected = [ + (key: "a", value: "b"), + (key: "sp ac & es", value: "d"), + (key: "dup", value: "e"), + (key: "", value: "foo"), + (key: "noval", value: ""), + (key: "emoji", value: "👀"), + (key: "jalapen\u{0303}os", value: "nfd"), + (key: Self.SpecialCharacters, value: "specials"), + (key: "dup", value: "f"), + (key: "jalape\u{00F1}os", value: "nfc"), + (key: "1+1", value: "2"), + ] + XCTAssertEqualKeyValuePairs(kvps, expected) + + // Single key lookup. + // This should be equivalent to 'kvps.first { $0.key == TheKey }?.value' + // (in other words, Unicode canonical equivalence of the percent-decoded key, interpreted as UTF-8). + + // Unique key (unescaped). + XCTAssertEqual(kvps["a"], "b") + XCTAssertEqual(kvps["emoji"], "👀") + + // Duplicate key (unescaped). + // Lookup returns the first value. + XCTAssertEqual(kvps["dup"], "e") + + // Not-present key. + XCTAssertEqual(kvps["doesNotExist"], nil) + XCTAssertEqual(kvps["jalapenos"], nil) + XCTAssertEqual(kvps["DUP"], nil) + + // Empty key/value. + XCTAssertEqual(kvps[""], "foo") + XCTAssertEqual(kvps["noval"], "") + + // Keys which need unescaping. + XCTAssertEqual(kvps["sp ac & es"], "d") + XCTAssertEqual(kvps["1+1"], "2") + XCTAssertEqual(kvps[Self.SpecialCharacters], "specials") + + // Unicode keys. + // Lookup uses canonical equivalence, so these match the same pair. + XCTAssertEqual(kvps["jalapen\u{0303}os"], "nfd") + XCTAssertEqual(kvps["jalape\u{00F1}os"], "nfd") + + // Multiple key lookup. + // Each key should be looked up as above. + + XCTAssertEqual(kvps["dup", "dup"], ("e", "e")) + XCTAssertEqual(kvps["jalapen\u{0303}os", "emoji", "jalape\u{00F1}os"], ("nfd", "👀", "nfd")) + XCTAssertEqual(kvps["1+1", "dup", "", "sp ac & es"], ("2", "e", "foo", "d")) + XCTAssertEqual(kvps["noval", "doesNotExist", "DUP"], ("", nil, nil)) + } + + // Form encoding in the query. + + do { + let url = WebURL("http://example/?a=b&sp%20ac+%26+es=d&dup=e&=foo&noval&emoji=👀&jalapen\u{0303}os=nfd&\(Self.SpecialCharacters_Escaped_Form)=specials&dup=f&jalape\u{00F1}os=nfc&1%2B1=2#frag")! + _testKeyLookupSubscript(url.queryParams) + } + + // Form encoding in the query (2). + // Semicolons are also allowed as pair delimiters. + + do { + let url = WebURL("http://example/?a=b&sp%20ac+%26+es=d;dup=e&=foo&noval&emoji=👀;jalapen\u{0303}os=nfd;\(Self.SpecialCharacters_Escaped_Form)=specials&dup=f&jalape\u{00F1}os=nfc&1%2B1=2#frag")! + _testKeyLookupSubscript(url.keyValuePairs(in: .query, schema: ExtendedForm(semicolonIsPairDelimiter: true))) + } + + // Custom schema in the fragment. + + do { + let url = WebURL("http://example/?srch#a:b,sp%20ac%20&%20es:d,dup:e,:foo,noval,emoji:👀,jalapen\u{0303}os:nfd,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):specials,dup:f,jalape\u{00F1}os:nfc,1+1:2")! + _testKeyLookupSubscript(url.keyValuePairs(in: .fragment, schema: CommaSeparated())) + } + } + + /// Tests using `allValues(forKey:)` to find all values associated with a given key. + /// + /// Checks looking up values for: + /// + /// - Unique keys + /// - Duplicate keys + /// - Not-present keys + /// - Empty keys + /// - Keys which need unescaping, and + /// - Unicode keys + /// + /// **Tests are repeated**: + /// + /// 1. In various URL components, and + /// 2. Using a variety of schemas with different escaping rules and delimiters. + /// + func testAllValuesForKey() { + + func _testAllValuesForKey(_ kvps: WebURL.KeyValuePairs) { + + // Check the overall list of pairs is what we expect. + + let expected = [ + (key: "a", value: "b"), + (key: "sp ac & es", value: "d"), + (key: "dup", value: "e"), + (key: "", value: "foo"), + (key: "noval", value: ""), + (key: "emoji", value: "👀"), + (key: "jalapen\u{0303}os", value: "nfd"), + (key: Self.SpecialCharacters, value: "specials"), + (key: "dup", value: "f"), + (key: "jalape\u{00F1}os", value: "nfc"), + (key: "1+1", value: "2"), + (key: "DUP", value: "no"), + ] + XCTAssertEqualKeyValuePairs(kvps, expected) + + // Unique keys. + XCTAssertEqual(kvps.allValues(forKey: "sp ac & es"), ["d"]) + XCTAssertEqual(kvps.allValues(forKey: "1+1"), ["2"]) + XCTAssertEqual(kvps.allValues(forKey: Self.SpecialCharacters), ["specials"]) + + // Duplicate keys. + // Values must be in the same order as in the list. + XCTAssertEqual(kvps.allValues(forKey: "dup"), ["e", "f"]) + + // Not-present keys. + XCTAssertEqual(kvps.allValues(forKey: "doesNotExist"), []) + XCTAssertEqual(kvps.allValues(forKey: "EMOJI"), []) + + // Empty keys/values. + XCTAssertEqual(kvps.allValues(forKey: ""), ["foo"]) + XCTAssertEqual(kvps.allValues(forKey: "noval"), [""]) + + // Unicode keys. + // Lookup uses canonical equivalence. + XCTAssertEqual(kvps.allValues(forKey: "jalapen\u{0303}os"), ["nfd", "nfc"]) + XCTAssertEqual(kvps.allValues(forKey: "jalape\u{00F1}os"), ["nfd", "nfc"]) + } + + // Form encoding in the query. + + do { + let url = WebURL("http://example/?a=b&sp%20ac+%26+es=d&dup=e&=foo&noval&emoji=👀&jalapen\u{0303}os=nfd&\(Self.SpecialCharacters_Escaped_Form)=specials&dup=f&jalape\u{00F1}os=nfc&1%2B1=2&DUP=no#frag")! + _testAllValuesForKey(url.queryParams) + } + + // Form encoding in the query (2). + // Semi-colons allowed as pair delimiters. + + do { + let url = WebURL("http://example/?a=b&sp%20ac+%26+es=d;dup=e&=foo&noval&emoji=👀;jalapen\u{0303}os=nfd;\(Self.SpecialCharacters_Escaped_Form)=specials&dup=f&jalape\u{00F1}os=nfc&1%2B1=2&DUP=no#frag")! + _testAllValuesForKey(url.keyValuePairs(in: .query, schema: ExtendedForm(semicolonIsPairDelimiter: true))) + } + + // Custom schema in the fragment. + + do { + let url = WebURL("http://example/?srch#a:b,sp%20ac%20&%20es:d,dup:e,:foo,noval,emoji:👀,jalapen\u{0303}os:nfd,\(Self.SpecialCharacters_Escaped_CommaSep_Frag):specials,dup:f,jalape\u{00F1}os:nfc,1+1:2,DUP:no")! + _testAllValuesForKey(url.keyValuePairs(in: .fragment, schema: CommaSeparated())) + } + } +} + + // ------------------------------- // MARK: - Writing: By Key. // ------------------------------- @@ -3019,9 +3448,9 @@ extension KeyValuePairsTests { // First key. "http://example/p?foo=first&dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form)&caf%C3%A9=cheese&dup#frag", // Multiple values. - "http://example/p?foo=bar&dup=\(Self.SpecialCharacters_Escaped_MinPctEnc_Query)&=x&\(Self.SpecialCharacters_Escaped_Form)&caf%C3%A9=cheese#frag", + "http://example/p?foo=bar&dup=\(Self.SpecialCharacters_Escaped_PrctEnc_Query)&=x&\(Self.SpecialCharacters_Escaped_Form)&caf%C3%A9=cheese#frag", // Appended pair. - "http://example/p?foo=bar&dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form)&caf%C3%A9=cheese&dup&inserted-\(Self.SpecialCharacters_Escaped_MinPctEnc_Query)=yes#frag", + "http://example/p?foo=bar&dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form)&caf%C3%A9=cheese&dup&inserted-\(Self.SpecialCharacters_Escaped_PrctEnc_Query)=yes#frag", // Set value to the empty string. // >> Since the KVP does not include a key-value delimiter, none is added. "http://example/p?foo=bar&dup&=x&dup&\(Self.SpecialCharacters_Escaped_Form)&caf%C3%A9=cheese&dup#frag", @@ -3095,7 +3524,7 @@ extension KeyValuePairsTests { // Custom schema in the fragment. _testSet( url: "http://example/p?q#foo:bar,dup,:x,dup,\(Self.SpecialCharacters_Escaped_Form):test,caf%C3%A9:cheese,dup", - component: .fragment, schema: CommaSeparated(minimalPercentEncoding: true), checkpoints: [ + component: .fragment, schema: CommaSeparated(), checkpoints: [ // Single value. "http://example/p?q#foo:bar,dup,:x,dup,\(Self.SpecialCharacters_Escaped_Form):found,caf%C3%A9:cheese,dup", // Single value (Unicode). @@ -3103,9 +3532,9 @@ extension KeyValuePairsTests { // First key. "http://example/p?q#foo:first,dup,:x,dup,\(Self.SpecialCharacters_Escaped_Form):test,caf%C3%A9:cheese,dup", // Multiple values. - "http://example/p?q#foo:bar,dup:\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag),:x,\(Self.SpecialCharacters_Escaped_Form):test,caf%C3%A9:cheese", + "http://example/p?q#foo:bar,dup:\(Self.SpecialCharacters_Escaped_CommaSep_Frag),:x,\(Self.SpecialCharacters_Escaped_Form):test,caf%C3%A9:cheese", // Appended pair. - "http://example/p?q#foo:bar,dup,:x,dup,\(Self.SpecialCharacters_Escaped_Form):test,caf%C3%A9:cheese,dup,inserted-\(Self.SpecialCharacters_Escaped_MinCommaSep_Frag):yes", + "http://example/p?q#foo:bar,dup,:x,dup,\(Self.SpecialCharacters_Escaped_Form):test,caf%C3%A9:cheese,dup,inserted-\(Self.SpecialCharacters_Escaped_CommaSep_Frag):yes", // Set value to the empty string. "http://example/p?q#foo:bar,dup,:x,dup,\(Self.SpecialCharacters_Escaped_Form):,caf%C3%A9:cheese,dup", // Set value for empty key. @@ -3299,7 +3728,7 @@ extension KeyValuePairsTests { XCTAssertEqual(url.serialized(), "p://x/?Tes%74t123xAno%74her%20%74es%74t456xresul%74ste%78cellen%74") let retrieved = url.keyValuePairs(in: .query, schema: AlphaDelimiters()).map { KeyValuePair($0) } - XCTAssertEqual(retrieved, pairsToAdd.map { KeyValuePair($0) }) + XCTAssertEqual(retrieved, pairsToAdd.map { KeyValuePair(key: $0.0, value: $0.1) }) } } diff --git a/Tests/WebURLTests/KeyValuePairs/KeyValueStringSchemaTests.swift b/Tests/WebURLTests/KeyValuePairs/KeyValueStringSchemaTests.swift new file mode 100644 index 000000000..f45d0bbb6 --- /dev/null +++ b/Tests/WebURLTests/KeyValuePairs/KeyValueStringSchemaTests.swift @@ -0,0 +1,183 @@ +// Copyright The swift-url Contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest + +@testable import WebURL + +#if swift(<5.7) + #error("WebURL.KeyValuePairs requires Swift 5.7 or newer") +#endif + +final class KeyValueStringSchemaTests: XCTestCase { + + /// Verifies known schemas. + /// + func testKnownSchemas() { + + for component in KeyValuePairsSupportedComponent.allCases { + // Built-in schemas support all components. + XCTAssertNoThrow(try FormCompatibleKeyValueString().verify(for: component)) + XCTAssertNoThrow(try PercentEncodedKeyValueString().verify(for: component)) + + // Custom schemas defined in 'KeyValuePairs/Helpers.swift' support all components. + XCTAssertNoThrow(try CommaSeparated().verify(for: component)) + XCTAssertNoThrow(try ExtendedForm(semicolonIsPairDelimiter: true).verify(for: component)) + XCTAssertNoThrow(try ExtendedForm(encodeSpaceAsPlus: true).verify(for: component)) + } + } + + /// Tests that `KeyValueStringSchema.verify(for:)` detects mistakes in custom schemas. + /// + func testSchemaVerification() { + + struct VariableSchema: KeyValueStringSchema { + + var preferredKeyValueDelimiter = UInt8(ascii: "=") + var preferredPairDelimiter = UInt8(ascii: "&") + var decodePlusAsSpace = false + var encodeSpaceAsPlus = false + + var _isPairDelimiter: (Self, UInt8) -> Bool = { schema, codePoint in + codePoint == schema.preferredPairDelimiter + } + var _isKeyValueDelimiter: (Self, UInt8) -> Bool = { schema, codePoint in + codePoint == schema.preferredKeyValueDelimiter + } + var _shouldPercentEncode: (Self, UInt8) -> Bool = { _, _ in + false + } + + func isPairDelimiter(_ codePoint: UInt8) -> Bool { + _isPairDelimiter(self, codePoint) + } + func isKeyValueDelimiter(_ codePoint: UInt8) -> Bool { + _isKeyValueDelimiter(self, codePoint) + } + func shouldPercentEncode(ascii codePoint: UInt8) -> Bool { + _shouldPercentEncode(self, codePoint) + } + } + + struct TestCase { + var keyPath: WritableKeyPath + var error: KeyValueStringSchemaVerificationFailure + } + + for component in KeyValuePairsSupportedComponent.allCases { + + XCTAssertNoThrow(try VariableSchema().verify(for: component)) + + // Invalid preferred delimiters. + // Preferred delimiters are special because we have to be able to write them unescaped to the URL string. + + let preferredDelimiterTests = [ + TestCase(keyPath: \.preferredKeyValueDelimiter, error: .preferredKeyValueDelimiterIsInvalid), + TestCase(keyPath: \.preferredPairDelimiter, error: .preferredPairDelimiterIsInvalid), + ] + for testcase in preferredDelimiterTests { + for v in UInt8.min...UInt8.max { + let error: KeyValueStringSchemaVerificationFailure? + do { + var schema = VariableSchema() + schema[keyPath: testcase.keyPath] = v + try schema.verify(for: component) + error = nil + } catch let e { + error = (e as! KeyValueStringSchemaVerificationFailure) + } + + // The delimiter: + // + // - Must be an ASCII code-point, + guard let ascii = ASCII(v) else { + XCTAssertEqual(error, testcase.error) + continue + } + switch ascii { + // - Must not be the percent sign (`%`), plus sign (`+`), space, or a hex digit, and + case ASCII.percentSign, ASCII.plus, ASCII.space: + XCTAssertEqual(error, testcase.error) + case _ where ascii.isHexDigit: + XCTAssertEqual(error, testcase.error) + // - Must not require escaping in the URL component(s) used with this schema. + default: + switch component.value { + case .query: + let needsEscaping = + URLEncodeSet.Query().shouldPercentEncode(ascii: v) + || URLEncodeSet.SpecialQuery().shouldPercentEncode(ascii: v) + XCTAssertEqual(error, needsEscaping ? testcase.error : nil) + case .fragment: + let needsEscaping = URLEncodeSet.Fragment().shouldPercentEncode(ascii: v) + XCTAssertEqual(error, needsEscaping ? testcase.error : nil) + } + } + } + } + + // Preferred delimiter not recognized by 'is(KeyValue/Pair)Delimter'. + // These must be consistent, else the view will produce unexpected results + // when writing entries and reading them back. + + let delimiterPredicateTests_0 = [ + TestCase(keyPath: \._isKeyValueDelimiter, error: .preferredKeyValueDelimiterNotRecognized), + TestCase(keyPath: \._isPairDelimiter, error: .preferredPairDelimiterNotRecognized), + ] + for testcase in delimiterPredicateTests_0 { + var schema = VariableSchema() + schema[keyPath: testcase.keyPath] = { _, codePoint in false } + XCTAssertThrowsSpecific(testcase.error, { try schema.verify(for: component) }) + } + + // Invalid characters recognized as delimiters. + // We cannot allow '%' or ASCII hex digits to be interpreted as delimiters, + // otherwise they would collide with percent-encoding. + // '+' is also disallowed, even when 'decodePlusAsSpace=false', because it's just a bad idea. + + let delimiterPredicateTests_1 = [ + TestCase(keyPath: \._isKeyValueDelimiter, error: .invalidKeyValueDelimiterIsRecognized), + TestCase(keyPath: \._isPairDelimiter, error: .invalidPairDelimiterIsRecognized), + ] + for testcase in delimiterPredicateTests_1 { + for disallowedChar in "0123456789abcdefABCDEF%+" { + var schema = VariableSchema() + schema[keyPath: testcase.keyPath] = { schema, codePoint in + codePoint == schema.preferredKeyValueDelimiter + || codePoint == schema.preferredPairDelimiter + || codePoint == disallowedChar.asciiValue! + } + XCTAssertThrowsSpecific(testcase.error, { try schema.verify(for: component) }) + } + } + + // Inconsistent space encoding. + // If spaces are encoded as plus, pluses must also be decoded as space. + // All other combinations are allowed. + + for decode in [true, false] { + for encode in [true, false] { + let schema = VariableSchema(decodePlusAsSpace: decode, encodeSpaceAsPlus: encode) + if encode == true, decode == false { + XCTAssertThrowsSpecific(KeyValueStringSchemaVerificationFailure.inconsistentSpaceEncoding) { + try schema.verify(for: component) + } + } else { + XCTAssertNoThrow(try schema.verify(for: component)) + } + } + } + } + } +} diff --git a/Tests/WebURLTests/Utils.swift b/Tests/WebURLTests/Utils.swift index 20d969a8e..ec98705fe 100644 --- a/Tests/WebURLTests/Utils.swift +++ b/Tests/WebURLTests/Utils.swift @@ -21,7 +21,49 @@ import XCTest func XCTAssertEqualElements( _ left: Left, _ right: Right, file: StaticString = #file, line: UInt = #line ) where Left.Element == Right.Element, Left.Element: Equatable { - XCTAssertTrue(left.elementsEqual(right), file: file, line: line) + + var message = "Sequences are not equal:\n" + + var mismatch = false + var position = 0 + var leftIter = left.makeIterator() + var rightIter = right.makeIterator() + + while let leftElement = leftIter.next() { + guard let rightElement = rightIter.next() else { + mismatch = true + message += + """ + [\(position)...] + L: \(leftElement) + R: <>\n + """ + break + } + if leftElement != rightElement { + mismatch = true + message += + """ + [\(position)] + L: \(leftElement) + R: \(rightElement)\n + """ + } + position += 1 + } + if let rightElement = rightIter.next() { + mismatch = true + message += + """ + [\(position)...] + L: <> + R: \(rightElement)\n + """ + } + + if mismatch { + XCTFail(message, file: file, line: line) + } } /// Asserts that a closure throws a particular error.