#StackBounty: #swift #combine #publisher Unexpected Combine Publisher Behavior

Bounty: 200

I’m building a mortgage calculator as an exercise to learn Combine. Everything has been going swimmingly until I encountered a situation where I’m not getting deterministic published output from one of my Publishers when I unit test it. I’m not making any asynchronous calls. This is the problematic AnyPublisher:

public lazy var monthlyPayment: AnyPublisher<Double, Never> = {
    Publishers.CombineLatest3(financedAmount, monthlyRate, numberOfPayments)
        .print("montlyPayment", to: nil)
        .map { financedAmount, monthlyRate, numberOfPayments in
            let numerator = monthlyRate * pow((1 + monthlyRate), Double(numberOfPayments))
            let denominator = pow((1 + monthlyRate), Double(numberOfPayments)) - 1
            
            return financedAmount * (numerator / denominator)
        }
        .eraseToAnyPublisher()
}()

Let’s say I change the mortgage type from a 30 year to a 15 year, a few things happen:

  1. the numberOfPayments changes due to the change in the mortgage term (length)
  2. the monthlyRate due to a change in the mortgage term (length)

End Goal

My end goal is to wait for financedAmount, monthlyRate, and numberOfPayments publishers to finish doing their thing and when they’re ALL done, THEN compute the monthly payment. Merge 3 seems to pick up changes in each of the publishers and for each change, it computes and spits out output I don’t want.

Repo with problematic class and associated unit tests

What I’ve Tried

I’ve tried mucking around with MergeMany, Merge3, .collect(), but I can’t get the syntax right. I’ve Googled the snot out of this and looked for examples in public GitHub repos, but I’m coming up with nothing that’s germane to my situation. I’m trying to figure out what I’m mucking up and how to fix it.

Supporting Declarations

These are my declarations for the other publishers upon which monthlyPayment relies:

@Published var principalAmount: Double
@Published var mortgageTerm: MortgageTerm = .thirtyYear
@Published var downPaymentAmount: Double = 0.0

// monthlyRate replies upon annualRate, so I'm including annualRate above
internal lazy var monthlyRate: AnyPublisher<Double, Never> = {
  annualRate
      .print("monthlyRate", to: nil)
      .map { rate in
          rate / 12
      }
      .eraseToAnyPublisher()
}()

public lazy var annualRate: AnyPublisher<Double, Never> = {
  $mortgageTerm
      .print("annualRate", to: nil)
      .map { value -> Double in
          switch value {
          case .tenYear:
              return self.rates.tenYearFix
          case .fifteenYear:
              return self.rates.fifteenYearFix
          case .twentyYear:
              return self.rates.twentyYearFix
          case .thirtyYear:
              return self.rates.thirtyYearFix
          }
      }
      .map { $0 * 0.01 }
      .eraseToAnyPublisher()
}()

public lazy var financedAmount: AnyPublisher<Double, Never> = {
  Publishers.CombineLatest($principalAmount, $downPaymentAmount)
      .map { principal, downPayment in
          principal - downPayment
      }
      .eraseToAnyPublisher()
}()

public lazy var numberOfPayments: AnyPublisher<Double, Never> = {
  $mortgageTerm
      .print("numberOfPayments: ", to: nil)
      .map {
          Double($0.rawValue * 12)
      }
      .eraseToAnyPublisher()
}()

Update

I attempted to use Merge3 with .collect(), but my unit test is timing out on it. Here’s the updated monthlyPayment declaration:

 public lazy var monthlyPayment: AnyPublisher<Double, Never> = {
     Publishers.Merge3(financedAmount, monthlyRate, numberOfPayments)
         .collect()
         .map { mergedArgs in
             let numerator = mergedArgs[1] * pow((1 + mergedArgs[1]), mergedArgs[2])
             let denominator = pow((1 + mergedArgs[1]), mergedArgs[2]) - 1
             
             return mergedArgs[0] * (numerator / denominator)
         }
         .eraseToAnyPublisher()
 }()

The test now fails with a timeout and the .sink code is never called:

 func testMonthlyPayment() {
     // sut is initialized w/ principalAmount of $100,000 & downPaymentAmount of $20,000
     let sut = calculator
     
     let expectation = expectation(description: #function)
     
     let expectedPayments = [339.62, 445.84, 433.97, 542.46]
     
     sut.monthlyPayment
         .collect(4)
         .sink { actualMonthlyPayment in
             XCTAssertEqual(actualMonthlyPayment.map { $0.roundTo(places: 2) }, expectedPayments)
             expectation.fulfill()
         }
         .store(in: &subscriptions)
     
     // Initialized with 30 year fix with 20% down
     // Change term to 20 years
     sut.mortgageType = .twentyYear
     
     // Change the financedAmount
     sut.downPaymentAmount.value = 0.0
     
     waitForExpectations(timeout: 5, handler: nil)     
}


Get this bounty!!!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.