Skip to content

Commit 851b29f

Browse files
committed
[DependencyResolver/Tests] Add brute force correctness tests vs an oracle.
- This adds some test infrastructure to check the resolver behavior against an oracle which independently computes the correct solution against a trivial brute force enumeration of the entire solution space. - This doesn't yet handle situations where there are multiple solutions that satisfy the resolvers contract (but we may change the contract there). - This doesn't actually add any interesting tests, since the resolver itself doesn't yet handle the interesting situations.
1 parent 1002fbb commit 851b29f

File tree

1 file changed

+183
-5
lines changed

1 file changed

+183
-5
lines changed

Tests/PackageGraphTests/DependencyResolverTests.swift

Lines changed: 183 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import XCTest
1212

13+
import Basic
1314
import PackageGraph
1415

1516
import struct Utility.Version
@@ -52,13 +53,17 @@ private struct MockPackageContainer: PackageContainer {
5253
private struct MockPackagesProvider: PackageContainerProvider {
5354
typealias Container = MockPackageContainer
5455

55-
let containers: [MockPackageContainer]
56+
let containers: [Container]
57+
let containersByIdentifier: [Container.Identifier: Container]
58+
59+
init(containers: [MockPackageContainer]) {
60+
self.containers = containers
61+
self.containersByIdentifier = Dictionary(items: containers.map{ ($0.identifier, $0) })
62+
}
5663

5764
func getContainer(for identifier: Container.Identifier) throws -> Container {
58-
for container in containers {
59-
if container.name == identifier {
60-
return container
61-
}
65+
if let container = containersByIdentifier[identifier] {
66+
return container
6267
}
6368
throw MockLoadingError.unknownModule
6469
}
@@ -363,16 +368,189 @@ class DependencyResolverTests: XCTestCase {
363368
}
364369
}
365370

371+
/// Check completeness on a variety of synthetic graphs.
372+
func testCompleteness() throws {
373+
typealias ConstraintSet = MockDependencyResolver.ConstraintSet
374+
375+
// We check correctness by comparing the result to an oracle which implements a trivial brute force solver.
376+
377+
// Check respect for the input constraints on version selection.
378+
do {
379+
let provider = MockPackagesProvider(containers: [
380+
MockPackageContainer(name: "A", dependenciesByVersion: [
381+
v1: [], v1_1: []]),
382+
MockPackageContainer(name: "B", dependenciesByVersion: [
383+
v1: [], v1_1: []])
384+
])
385+
let resolver = MockDependencyResolver(provider, MockResolverDelegate())
386+
387+
// Check the maximal solution is picked.
388+
try checkResolution(resolver, constraints: [
389+
MockPackageConstraint(container: "A", versionRequirement: v1Range),
390+
MockPackageConstraint(container: "B", versionRequirement: v1Range)])
391+
}
392+
}
393+
366394
static var allTests = [
367395
("testBasics", testBasics),
368396
("testVersionSetSpecifier", testVersionSetSpecifier),
369397
("testContainerConstraintSet", testContainerConstraintSet),
370398
("testVersionAssignment", testVersionAssignment),
371399
("testResolveSubtree", testResolveSubtree),
372400
("testResolve", testResolve),
401+
("testCompleteness", testCompleteness),
373402
]
374403
}
375404

405+
/// Validate the solution made by `resolver` for the given `constraints`.
406+
///
407+
/// This checks that the solution is complete, correct, and maximal and that it
408+
/// does not contain spurious assignments.
409+
private func checkResolution(_ resolver: MockDependencyResolver, constraints: [MockPackageConstraint]) throws {
410+
// Compute the complete set of valid solution by brute force enumeration.
411+
func satisfiesConstraints(_ assignment: MockVersionAssignmentSet) -> Bool {
412+
for constraint in constraints {
413+
// FIXME: This is ambiguous, but currently the presence of a
414+
// constraint means the package is required.
415+
guard case let .version(version)? = assignment[constraint.identifier] else { return false }
416+
if !constraint.versionRequirement.contains(version) {
417+
return false
418+
}
419+
}
420+
return true
421+
}
422+
func isValidSolution(_ assignment: MockVersionAssignmentSet) -> Bool {
423+
// A solution is valid if it is consistent and complete, meets the input
424+
// constraints, and doesn't contain any unnecessary bindings.
425+
guard assignment.checkIfValidAndComplete() && satisfiesConstraints(assignment) else { return false }
426+
427+
// Check the assignment doesn't contain unnecessary bindings.
428+
let requiredContainers = transitiveClosure(constraints.map{ $0.identifier }, successors: { identifier in
429+
guard case let .version(version)? = assignment[identifier] else {
430+
fatalError("unexpected assignment")
431+
}
432+
let container = try! resolver.provider.getContainer(for: identifier)
433+
return [identifier] + container.getDependencies(at: version).map{ $0.identifier }
434+
})
435+
for (container, _) in assignment {
436+
if !requiredContainers.contains(container.identifier) {
437+
return false
438+
}
439+
}
440+
441+
return true
442+
}
443+
let validSolutions = allPossibleAssignments(for: resolver.provider).filter(isValidSolution)
444+
445+
// Compute the list of maximal solutions.
446+
var maximalSolutions = [MockVersionAssignmentSet]()
447+
for solution in validSolutions {
448+
// Eliminate any currently maximal solutions this one is greater than.
449+
let numPreviousSolutions = maximalSolutions.count
450+
maximalSolutions = maximalSolutions.filter{ !solution.isStrictlyGreater(than: $0) }
451+
452+
// If we eliminated any solution, then this is a new maximal solution.
453+
if maximalSolutions.count != numPreviousSolutions {
454+
assert(maximalSolutions.first(where: { $0.isStrictlyGreater(than: solution) }) == nil)
455+
maximalSolutions.append(solution)
456+
} else {
457+
// Otherwise, this is still a new maximal solution if it isn't comparable to any other one.
458+
if maximalSolutions.first(where: { $0.isStrictlyGreater(than: solution) }) == nil {
459+
maximalSolutions.append(solution)
460+
}
461+
}
462+
}
463+
464+
// FIXME: It is possible there are multiple maximal solutions, we don't yet
465+
// define the ordering required to establish what the "correct" answer is
466+
// here.
467+
if maximalSolutions.count > 1 {
468+
return XCTFail("unable to find a unique solution for input test case")
469+
}
470+
471+
// Get the resolver's solution.
472+
var solution: MockVersionAssignmentSet?
473+
do {
474+
solution = try resolver.resolveAssignment(constraints: constraints)
475+
} catch DependencyResolverError.unsatisfiable {
476+
solution = nil
477+
}
478+
479+
// Check the solution against our oracle.
480+
if let solution = solution {
481+
if maximalSolutions.count != 1 {
482+
return XCTFail("solver unexpectedly found: \(solution) when there are no viable solutions")
483+
}
484+
if solution != maximalSolutions[0] {
485+
return XCTFail("solver result: \(solution.map{ ($0.0.identifier, $0.1) }) does not match expected result: \(maximalSolutions[0].map{ ($0.0.identifier, $0.1) })")
486+
}
487+
} else {
488+
if maximalSolutions.count != 0 {
489+
return XCTFail("solver was unable to find the valid solution: \(validSolutions[0])")
490+
}
491+
}
492+
}
493+
494+
/// Compute a sequence of all possible assignments.
495+
private func allPossibleAssignments(for provider: MockPackagesProvider) -> AnySequence<MockVersionAssignmentSet> {
496+
func allPossibleAssignments(for containers: AnyIterator<MockPackageContainer>) -> [MockVersionAssignmentSet] {
497+
guard let container = containers.next() else {
498+
// The empty list only has one assignment.
499+
return [MockVersionAssignmentSet()]
500+
}
501+
502+
// The result is all other assignments amended with an assignment of
503+
// this container to each possible version, or not included.
504+
//
505+
// FIXME: It would be nice to be lazy here...
506+
let otherAssignments = allPossibleAssignments(for: containers)
507+
return otherAssignments + container.versions.reversed().flatMap{ version in
508+
return otherAssignments.map{ assignment in
509+
var assignment = assignment
510+
assignment[container] = .version(version)
511+
return assignment
512+
}
513+
}
514+
}
515+
516+
return AnySequence(allPossibleAssignments(for: AnyIterator(provider.containers.makeIterator())))
517+
}
518+
519+
extension VersionAssignmentSet {
520+
/// Define a partial ordering among assignments.
521+
///
522+
/// This checks if an assignment has bindings which are strictly greater (as
523+
/// semantic versions) than those of `rhs`. Binding with excluded
524+
/// assignments are incomparable when the assignments differ.
525+
func isStrictlyGreater(than rhs: VersionAssignmentSet) -> Bool {
526+
// This set is strictly greater than `rhs` if every assigned version in
527+
// it is greater than or equal to those in `rhs`, and some assignment is
528+
// strictly greater.
529+
var hasGreaterAssignment = false
530+
for (container, rhsBinding) in rhs {
531+
guard let lhsBinding = self[container] else { return false }
532+
533+
switch (lhsBinding, rhsBinding) {
534+
case (.excluded, .excluded):
535+
// If the container is excluded in both assignments, it is ok.
536+
break
537+
case (.excluded, _), (_, .excluded):
538+
// If the container is excluded in one of the assignments, they are incomparable.
539+
return false
540+
case let (.version(lhsVersion), .version(rhsVersion)):
541+
if lhsVersion < rhsVersion {
542+
return false
543+
} else if lhsVersion > rhsVersion {
544+
hasGreaterAssignment = true
545+
}
546+
default:
547+
fatalError("unreachable")
548+
}
549+
}
550+
return hasGreaterAssignment
551+
}
552+
}
553+
376554
private extension DependencyResolver {
377555
func resolveSubtree(
378556
_ container: Container,

0 commit comments

Comments
 (0)