diff --git a/Sources/BuildTool/BuildTool.swift b/Sources/BuildTool/BuildTool.swift index ffaf9c98..34fa2d25 100644 --- a/Sources/BuildTool/BuildTool.swift +++ b/Sources/BuildTool/BuildTool.swift @@ -279,6 +279,9 @@ struct SpecCoverage: AsyncParsableCommand { case couldNotFindTestTarget case malformedSpecOneOfTag case specUntestedTagMissingComment + case specOneOfIncorrectTotals(specPointID: String, coverageTagTotals: [Int], actualTotal: Int) + case specOneOfIncorrectIndices(specPointID: String, coverageTagIndices: [Int], expectedIndices: [Int]) + case multipleConformanceTagTypes(specPointID: String, types: [String]) } /** @@ -324,6 +327,26 @@ struct SpecCoverage: AsyncParsableCommand { case specOneOf(index: Int, total: Int, comment: String?) case specPartial(comment: String?) case specUntested(comment: String) + + enum Case { + case spec + case specOneOf + case specPartial + case specUntested + } + + var `case`: Case { + switch self { + case .spec: + .spec + case .specOneOf: + .specOneOf + case .specPartial: + .specPartial + case .specUntested: + .specUntested + } + } } var type: `Type` @@ -424,7 +447,7 @@ struct SpecCoverage: AsyncParsableCommand { throw Error.conformanceToNonexistentSpecPoints(specPointIDs: invalidSpecPointIDs.sorted()) } - // 2. Find any conformance tags for non-testable spec points (see documentation of the `nonTestableSpecPointIDsWithConformanceTags` property) for motivation. + // 2. Find any conformance tags for non-testable spec points (see documentation of the `nonTestableSpecPointIDsWithConformanceTags` property for motivation). let specPointsByID = Dictionary(grouping: specFile.specPoints, by: \.id) var nonTestableSpecPointIDsWithConformanceTags: Set = [] @@ -436,37 +459,11 @@ struct SpecCoverage: AsyncParsableCommand { } } - // 3. Determine the coverage of each testable spec point. + // 3. Validate the spec coverage tags, and determine the coverage of each testable spec point. let testableSpecPoints = specFile.specPoints.filter(\.isTestable) - let specPointCoverages = testableSpecPoints.map { specPoint in - var coverageLevel: CoverageLevel? - var comments: [String] = [] - + let specPointCoverages = try testableSpecPoints.map { specPoint in let conformanceTagsForSpecPoint = conformanceTagsBySpecPointID[specPoint.id, default: []] - // TODO: https://github.com/ably-labs/ably-chat-swift/issues/96 - check for contradictory tags, validate the specOneOf(m, n) tags - for conformanceTag in conformanceTagsForSpecPoint { - // We only make use of the comments that explain why something is untested or partially tested. - switch conformanceTag.type { - case .spec: - coverageLevel = .tested - case .specOneOf: - coverageLevel = .tested - case let .specPartial(comment: comment): - coverageLevel = .partiallyTested - if let comment { - comments.append(comment) - } - case let .specUntested(comment: comment): - coverageLevel = .implementedButDeliberatelyNotTested - comments.append(comment) - } - } - - return SpecPointCoverage( - specPointID: specPoint.id, - coverageLevel: coverageLevel ?? .notTested, - comments: comments - ) + return try generateCoverage(for: specPoint, conformanceTagsForSpecPoint: conformanceTagsForSpecPoint) } return .init( @@ -479,6 +476,78 @@ struct SpecCoverage: AsyncParsableCommand { nonTestableSpecPointIDsWithConformanceTags: nonTestableSpecPointIDsWithConformanceTags ) } + + /// Validates the spec coverage tags for this spec point, and determines its coverage. + private static func generateCoverage(for specPoint: SpecFile.SpecPoint, conformanceTagsForSpecPoint: [ConformanceTag]) throws -> SpecPointCoverage { + // Calculated data to be used in output + var coverageLevel: CoverageLevel? + var comments: [String] = [] + + // Bookkeeping data for validation of conformance tags + var specOneOfDatas: [(index: Int, total: Int)] = [] + var conformanceTagTypeCases: Set = [] + + for conformanceTag in conformanceTagsForSpecPoint { + // We only make use of the comments that explain why something is untested or partially tested. + switch conformanceTag.type { + case .spec: + coverageLevel = .tested + case let .specOneOf(index: index, total: total, _): + coverageLevel = .tested + specOneOfDatas.append((index: index, total: total)) + case let .specPartial(comment: comment): + coverageLevel = .partiallyTested + if let comment { + comments.append(comment) + } + case let .specUntested(comment: comment): + coverageLevel = .implementedButDeliberatelyNotTested + comments.append(comment) + } + + conformanceTagTypeCases.insert(conformanceTag.type.case) + } + + // Before returning, we validate the conformance tags for this spec point: + + // 1. Check we don't have more than one type of conformance tag for this spec point. + if conformanceTagTypeCases.count > 1 { + throw Error.multipleConformanceTagTypes( + specPointID: specPoint.id, + types: conformanceTagTypeCases.map { "\($0)" } + ) + } + + // 2. Validate the data attached to the @specOneOf(m/n) conformance tags. + if !specOneOfDatas.isEmpty { + // Do the totals stated in the tags match the number of tags? + let coverageTagTotals = specOneOfDatas.map(\.total) + if !(coverageTagTotals.allSatisfy { $0 == specOneOfDatas.count }) { + throw Error.specOneOfIncorrectTotals( + specPointID: specPoint.id, + coverageTagTotals: specOneOfDatas.map(\.total), + actualTotal: specOneOfDatas.count + ) + } + + // Are the indices as expected? + let coverageTagIndices = specOneOfDatas.map(\.index).sorted() + let expectedIndices = Array(1 ... specOneOfDatas.count) + if coverageTagIndices != expectedIndices { + throw Error.specOneOfIncorrectIndices( + specPointID: specPoint.id, + coverageTagIndices: coverageTagIndices, + expectedIndices: expectedIndices + ) + } + } + + return SpecPointCoverage( + specPointID: specPoint.id, + coverageLevel: coverageLevel ?? .notTested, + comments: comments + ) + } } private struct CoverageReportViewModel { diff --git a/Tests/AblyChatTests/MessageTests.swift b/Tests/AblyChatTests/MessageTests.swift deleted file mode 100644 index 1e8bc084..00000000 --- a/Tests/AblyChatTests/MessageTests.swift +++ /dev/null @@ -1,97 +0,0 @@ -@testable import AblyChat -import Testing - -struct MessageTests { - let earlierMessage = Message( - serial: "ABC123@1631840000000-5:2", - action: .create, - clientID: "testClientID", - roomID: "roomId", - text: "hello", - createdAt: nil, - metadata: [:], - headers: [:], - version: "ABC123@1631840000000-5:2", - timestamp: nil - ) - - let laterMessage = Message( - serial: "ABC123@1631840000001-5:2", - action: .create, - clientID: "testClientID", - roomID: "roomId", - text: "hello", - createdAt: nil, - metadata: [:], - headers: [:], - version: "ABC123@1631840000000-5:2", - timestamp: nil - ) - - let invalidMessage = Message( - serial: "invalid", - action: .create, - clientID: "testClientID", - roomID: "roomId", - text: "hello", - createdAt: nil, - metadata: [:], - headers: [:], - version: "invalid", - timestamp: nil - ) - - // MARK: isBefore Tests - - // @specOneOf(1/3) CHA-M2a - @Test - func isBefore_WhenMessageIsBefore_ReturnsTrue() async throws { - #expect(earlierMessage.serial < laterMessage.serial) - } - - // @specOneOf(2/3) CHA-M2a - @Test - func isBefore_WhenMessageIsNotBefore_ReturnsFalse() async throws { - #expect(laterMessage.serial > earlierMessage.serial) - } - - // MARK: isAfter Tests - - // @specOneOf(1/3) CHA-M2b - @Test - func isAfter_whenMessageIsAfter_ReturnsTrue() async throws { - #expect(laterMessage.serial > earlierMessage.serial) - } - - // @specOneOf(2/3) CHA-M2b - @Test - func isAfter_whenMessageIsNotAfter_ReturnsFalse() async throws { - #expect(earlierMessage.serial < laterMessage.serial) - } - - // MARK: isEqual Tests - - // @specOneOf(1/3) CHA-M2c - @Test - func isEqual_whenMessageIsEqual_ReturnsTrue() async throws { - let duplicateOfEarlierMessage = Message( - serial: "ABC123@1631840000000-5:2", - action: .create, - clientID: "random", - roomID: "", - text: "", - createdAt: nil, - metadata: [:], - headers: [:], - version: "ABC123@1631840000000-5:2", - timestamp: nil - ) - #expect(earlierMessage.serial == duplicateOfEarlierMessage.serial) - } - - // @specOneOf(2/3) CHA-M2c - @Test - func isEqual_whenMessageIsNotEqual_ReturnsFalse() async throws { - #expect(earlierMessage.serial != laterMessage.serial) - } -}