diff --git a/Flipcash/Core/Screens/Main/ExchangedBalance.swift b/Flipcash/Core/Screens/Main/ExchangedBalance.swift index f4e75ad37..f9c55598d 100644 --- a/Flipcash/Core/Screens/Main/ExchangedBalance.swift +++ b/Flipcash/Core/Screens/Main/ExchangedBalance.swift @@ -26,6 +26,12 @@ extension Array where Element == ExchangedBalance { fundable() } + /// These balances without Dollars, for a picker that names a community currency: dollars + /// is the stable token, not one. + func excludingUSDF() -> [ExchangedBalance] { + filter { $0.stored.mint != .usdf } + } + /// Balances eligible to withdraw. func withdrawable() -> [ExchangedBalance] { fundable() diff --git a/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift b/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift index a737012ad..65f40bc9b 100644 --- a/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift +++ b/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift @@ -9,7 +9,7 @@ import SwiftUI import FlipcashUI import FlipcashCore -struct SelectCurrencyScreen: View { +struct SelectCurrencyScreen: View { @Binding var isPresented: Bool @@ -19,39 +19,40 @@ struct SelectCurrencyScreen: View { let action: (ExchangedBalance) -> Void let isEnabled: (ExchangedBalance) -> Bool let isSelected: ((ExchangedBalance) -> Bool)? - private let header: Header? - private let listTitle: String? + let excludesUSDF: Bool + private let footer: Footer? private var balances: [ExchangedBalance] { - session.balances(for: ratesController.rateForBalanceCurrency()) + let giveable = session.balances(for: ratesController.rateForBalanceCurrency()) .giveable() + return excludesUSDF ? giveable.excludingUSDF() : giveable } - /// Creates a picker with `header` drawn above the currency list, and `listTitle` labelling the - /// list beneath it. + /// Creates a picker with `footer` drawn as the last item of the currency list. /// - /// The header spans the list's full width and sets its own margins. It stays on screen when - /// there is nothing giveable to list, since it is a choice of its own rather than one of the - /// list's rows. + /// The footer is a row of its own and sets its own margins. It stays on screen when there is + /// nothing giveable to list, since it is a choice of its own rather than one of the balances. /// /// - Parameter isSelected: which row draws its checkmark. Defaults to the currency the wallet is /// denominated in, which is what picking one here changes. Pass a closure when the screen is /// picking for something else — a group chat's balance requirement, say — so the check follows /// that choice instead of the wallet's. + /// - Parameter excludesUSDF: drops Dollars from the list, for a screen that picks a community + /// currency. Defaults to showing every giveable balance. init( isPresented: Binding, + excludesUSDF: Bool = false, isEnabled: @escaping (ExchangedBalance) -> Bool = { _ in true }, isSelected: ((ExchangedBalance) -> Bool)? = nil, - listTitle: String, action: @escaping (ExchangedBalance) -> Void, - @ViewBuilder header: () -> Header + @ViewBuilder footer: () -> Footer ) { self._isPresented = isPresented + self.excludesUSDF = excludesUSDF self.isEnabled = isEnabled self.isSelected = isSelected self.action = action - self.header = header() - self.listTitle = listTitle + self.footer = footer() } var body: some View { @@ -61,7 +62,7 @@ struct SelectCurrencyScreen: View { NavigationStack { Background(color: .backgroundMain) { - if balances.isEmpty && header == nil { + if balances.isEmpty && footer == nil { Text("No currencies to give") .font(.appTextMedium) .foregroundStyle(Color.textSecondary) @@ -71,24 +72,6 @@ struct SelectCurrencyScreen: View { .accessibilityIdentifier("give-picker-empty") } else { List { - if let header { - header - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - - if let listTitle, !balances.isEmpty { - Text(listTitle) - .font(.default(size: 15, weight: .semibold)) - .foregroundStyle(Color.textMain.opacity(0.5)) - .padding(.top, 24) - .padding(.bottom, 4) - .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } - } - Section { ForEach(balances) { balance in let enabled = isEnabled(balance) @@ -108,6 +91,13 @@ struct SelectCurrencyScreen: View { } } .listRowInsets(EdgeInsets()) + + if let footer { + footer + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } } .listStyle(.plain) .scrollContentBackground(.hidden) @@ -124,7 +114,7 @@ struct SelectCurrencyScreen: View { } } -extension SelectCurrencyScreen where Header == EmptyView { +extension SelectCurrencyScreen where Footer == EmptyView { /// Creates a picker listing only the giveable currencies. /// @@ -137,11 +127,11 @@ extension SelectCurrencyScreen where Header == EmptyView { action: @escaping (ExchangedBalance) -> Void ) { self._isPresented = isPresented + self.excludesUSDF = false self.isEnabled = isEnabled self.isSelected = isSelected self.action = action - self.header = nil - self.listTitle = nil + self.footer = nil } } diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/AllCurrenciesCard.swift b/Flipcash/Core/Screens/Main/Tips/New Group/AllCurrenciesCard.swift deleted file mode 100644 index a7f31ac3b..000000000 --- a/Flipcash/Core/Screens/Main/Tips/New Group/AllCurrenciesCard.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// AllCurrenciesCard.swift -// Flipcash -// - -import SwiftUI -import FlipcashCore -import FlipcashUI - -/// The currency picker's "Total Balance" choice for a group's balance requirement, stating the -/// user's total against the amount picked (node 10370:997). -struct AllCurrenciesCard: View { - - let isSelected: Bool - /// Every holding added together, in USD — the figure the requirement is weighed against. - let total: FiatAmount - /// The amount picked on the form, or nil while none is. - let requirement: FiatAmount? - let meetsRequirement: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(spacing: 12) { - AllCurrenciesIcon(size: 32) - - VStack(alignment: .leading, spacing: 2) { - Text("Total Balance") - .font(.appBarButton) - .foregroundStyle(Color.textMain) - - Text(subtitle) - .font(.default(size: 13, weight: .medium)) - .foregroundStyle(meetsRequirement ? Color.textMain.opacity(0.5) : Color.textError) - .lineLimit(1) - .minimumScaleFactor(0.8) - } - .frame(maxWidth: .infinity, alignment: .leading) - - RadioButton(isSelected: isSelected) - .accessibilityHidden(true) - } - .padding(.horizontal, 12) - .padding(.vertical, 16) - .background(Color.backgroundRow, in: .rect(cornerRadius: 12)) - .overlay { - RoundedRectangle(cornerRadius: 12) - .strokeBorder(Color.white.opacity(isSelected ? 0.5 : 0.15)) - } - .contentShape(.rect(cornerRadius: 12)) - } - // Margin plus padding matches the currency rows' 20pt inset, so the icon and check sit on - // the same lines as the rows' icons and checks. - .padding(.horizontal, 8) - .buttonStyle(.plain) - .accessibilityAddTraits(isSelected ? .isSelected : []) - .accessibilityIdentifier("all-currencies-card") - } - - private var subtitle: String { - // Rounded before it is shown, because the gate compares the rounded figure. - let held = "You have \(total.roundedToSmallestUnit().formatted()) total" - guard let requirement else { return held } - let verdict = meetsRequirement ? "meets" : "need" - return "\(held) · \(verdict) \(requirement.formattedDroppingZeroFraction())" - } -} - -/// The coins glyph on a grey disc that stands in for a token's icon when a requirement counts every -/// holding (node 10369:999). -struct AllCurrenciesIcon: View { - - let size: CGFloat - - var body: some View { - Circle() - // White at 15% over the sheet's charcoal is the design's #3B3B3D. - .fill(Color.white.opacity(0.15)) - .frame(width: size, height: size) - .overlay { - Image.asset(.coins) - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: size * 0.625, height: size * 0.625) - .foregroundStyle(Color.textMain) - } - } -} diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/AnyCommunityCurrencyRow.swift b/Flipcash/Core/Screens/Main/Tips/New Group/AnyCommunityCurrencyRow.swift new file mode 100644 index 000000000..9c5b8d70e --- /dev/null +++ b/Flipcash/Core/Screens/Main/Tips/New Group/AnyCommunityCurrencyRow.swift @@ -0,0 +1,102 @@ +// +// AnyCommunityCurrencyRow.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// What a group's balance requirement is named when it counts any community currency rather than +/// one token. +enum AnyCommunityCurrency { + static let name = "Any Community Currency" +} + +/// The currency picker's "Any Community Currency" entry, the last item of its list. Laid out like +/// ``CurrencyBalanceRow`` so its icon and check sit on the same lines as the token rows'. +struct AnyCommunityCurrencyRow: View { + + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + AllCurrenciesIcon(size: 24) + + Text(AnyCommunityCurrency.name) + .font(.appBarButton) + .foregroundStyle(Color.textMain) + + Spacer() + + RadioButton(isSelected: isSelected) + .accessibilityHidden(true) + .padding(.leading, 12) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .listRowBackground(Color.clear) + .padding(.horizontal, 20) + .padding(.vertical, 20) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .accessibilityIdentifier("any-community-currency-row") + } +} + +/// The form's tappable currency row: the chosen token, or the any-currency glyph, and a chevron. +struct CommunityCurrencyRow: View { + + let name: String + let action: () -> Void + @ViewBuilder let icon: () -> Icon + + var body: some View { + Button(action: action) { + HStack { + HStack(spacing: 8) { + icon() + Text(name) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer() + Image(systemName: "chevron.right") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + .padding(.horizontal, 16) + .frame(height: 56) + .frame(maxWidth: .infinity) + .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + } + .buttonStyle(.plain) + } +} + +/// The coins glyph on a grey disc that stands in for a token's icon when a requirement counts any +/// community currency (node 10369:999). +struct AllCurrenciesIcon: View { + + let size: CGFloat + + var body: some View { + Circle() + // White at 15% over the sheet's charcoal is the design's #3B3B3D. + .fill(Color.white.opacity(0.15)) + .frame(width: size, height: size) + .overlay { + Image.asset(.coins) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: size * 0.625, height: size * 0.625) + .foregroundStyle(Color.textMain) + } + } +} diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupModel.swift b/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupModel.swift index 158ec36bf..bd381b6f8 100644 --- a/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupModel.swift +++ b/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupModel.swift @@ -32,9 +32,9 @@ final class NewPublicGroupModel { /// leaves the blob unset for a group with no picture. private(set) var picture: UIImage? - /// Which holdings count toward the requirement. Opens on ``CurrencyChoice/all``, the rule every + /// Which holdings count toward the requirement. Opens on ``CurrencyChoice/anyCommunityCurrency``, the rule every /// creator can draw without first having to pick a token. - private(set) var currency: CurrencyChoice = .all + private(set) var currency: CurrencyChoice = .anyCommunityCurrency /// The minimum balance a member must hold, in USD. private(set) var minimumBalance: FiatAmount? @@ -87,8 +87,8 @@ final class NewPublicGroupModel { currency = .specific(balance) } - func selectAllCurrencies() { - currency = .all + func selectAnyCommunityCurrency() { + currency = .anyCommunityCurrency } func select(minimumBalance: FiatAmount) { @@ -125,12 +125,14 @@ final class NewPublicGroupModel { return Self.rules(minimumBalance: minimumBalance, currency: currency) } - /// Whether the user's holdings added together clear the amount picked, whatever - /// ``currency`` is — what the picker's Total Balance card states. True while no amount is - /// picked. - func satisfiesAllCurrencies(session: some ConversationGateReading, rates: [CurrencyCode: Rate]) -> Bool { - let rules = minimumBalance.map { Self.rules(minimumBalance: $0, currency: .all) } - return conversationGate(session: session, rules: rules, rates: rates).listener.isSatisfied + /// What sits under the presets: who can join, and with how much of which currency. + var requirementCaption: String { + let currencyName: String + switch currency { + case .anyCommunityCurrency: currencyName = "any community currency" + case .specific(let balance): currencyName = balance.stored.name + } + return "People need this amount of \(currencyName) to join this chat." } /// The contract caps the mint list at one entry, and an empty list is the requirement applying @@ -306,8 +308,9 @@ extension NewPublicGroupModel { /// The holdings a group's balance requirement counts. enum CurrencyChoice: Equatable { - /// Every holding added together. - case all + /// Every holding added together, sent as an empty mint list. The contract can't say + /// "community currencies only", so USDF counts too. + case anyCommunityCurrency /// One holding, kept whole so the form's row can draw the token's name and icon. Only /// ``ExchangedBalance/stored``'s mint reaches the wire. case specific(ExchangedBalance) @@ -315,7 +318,7 @@ extension NewPublicGroupModel { /// The mint of the chosen holding, or nil when every holding counts. var mint: PublicKey? { switch self { - case .all: return nil + case .anyCommunityCurrency: return nil case .specific(let balance): return balance.stored.mint } } diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift b/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift index 777798624..604d2f18f 100644 --- a/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift @@ -53,6 +53,8 @@ struct NewPublicGroupScreen: View { switch model.privacy { case .public: + currencySection + .padding(.top, 37) requirementSection .padding(.top, 37) case .private: @@ -97,23 +99,16 @@ struct NewPublicGroupScreen: View { .sheet(isPresented: $isPickingCurrency) { SelectCurrencyScreen( isPresented: $isPickingCurrency, + // Dollars isn't a community currency; "Any Community Currency" is the last row. + excludesUSDF: true, // The check follows the requirement's mint, not the wallet's denomination — this // screen is picking what the gate weighs, not what the balance is shown in. - isSelected: { $0.stored.mint == model.currency.mint }, - listTitle: "Specific Currency" + isSelected: { $0.stored.mint == model.currency.mint } ) { balance in model.select(balance: balance) - } header: { - AllCurrenciesCard( - isSelected: model.currency == .all, - total: session.totalBalance.usdfValue, - requirement: model.minimumBalance, - meetsRequirement: model.satisfiesAllCurrencies( - session: session, - rates: ratesController.cachedRates - ) - ) { - model.selectAllCurrencies() + } footer: { + AnyCommunityCurrencyRow(isSelected: model.currency == .anyCommunityCurrency) { + model.selectAnyCommunityCurrency() isPickingCurrency = false } } @@ -161,26 +156,43 @@ struct NewPublicGroupScreen: View { .background(Color.backgroundRow, in: .rect(cornerRadius: Metrics.buttonRadius)) } - private var requirementSection: some View { + /// The holdings the requirement counts, opening the currency picker (node 11745:1054). + private var currencySection: some View { VStack(alignment: .leading, spacing: 10) { - Text("Minimum Balance Required") - .font(.default(size: 17, weight: .bold)) - .foregroundStyle(Color.textMain.opacity(0.5)) - .padding(.horizontal, 12) + sectionHeader("Community Currency") - VStack(alignment: .leading, spacing: 10) { - mintRow - presets + Group { + switch model.currency { + case .anyCommunityCurrency: + CommunityCurrencyRow(name: AnyCommunityCurrency.name, action: { isPickingCurrency = true }) { + AllCurrenciesIcon(size: 32) + } + case .specific(let balance): + CommunityCurrencyRow(name: balance.stored.name, action: { isPickingCurrency = true }) { + TokenIcon(url: balance.stored.imageURL, monogramID: balance.stored.mint.base58, size: 32) + } + } } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.backgroundRow, in: .rect(cornerRadius: Metrics.buttonRadius)) + .disabled(model.isCreating) + .accessibilityIdentifier("new-group-currency-row") + } + } - Text("People won't be able to join this chat if their balance is less than this amount") + private var requirementSection: some View { + VStack(alignment: .leading, spacing: 10) { + sectionHeader("Minimum Balance Required") + + presets + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.backgroundRow, in: .rect(cornerRadius: Metrics.buttonRadius)) + + Text(model.requirementCaption) .font(.default(size: 13, weight: .medium)) .foregroundStyle(Color.textMain.opacity(0.5)) .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 12) + .accessibilityIdentifier("new-group-requirement-caption") // The server refuses a chat whose creator is short of its own rules, so say so here // rather than spending the round trip to be told. @@ -195,38 +207,11 @@ struct NewPublicGroupScreen: View { } } - /// The holdings the requirement counts, opening the currency picker (nodes 10127:118254 and - /// 10364:1059). - private var mintRow: some View { - Button { isPickingCurrency = true } label: { - HStack(spacing: 4) { - switch model.currency { - case .all: - AllCurrenciesIcon(size: 20) - - Text("Total Balance") - .font(.appTextMedium) - .foregroundStyle(Color.textMain) - - case .specific(let balance): - RemoteImage(url: balance.stored.imageURL) - .frame(width: 20, height: 20) - .clipShape(Circle()) - - Text(balance.stored.name) - .font(.appTextMedium) - .foregroundStyle(Color.textMain) - } - - Image.system(.chevronDown) - .font(.appTextSmall) - .foregroundStyle(Color.textSecondary) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(model.isCreating) - .accessibilityIdentifier("new-group-currency-row") + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(.default(size: 17, weight: .bold)) + .foregroundStyle(Color.textMain.opacity(0.5)) + .padding(.horizontal, 12) } private var presets: some View { diff --git a/FlipcashTests/Chat/NewPublicGroupModelTests.swift b/FlipcashTests/Chat/NewPublicGroupModelTests.swift index 68af0b0d1..28e13d2e6 100644 --- a/FlipcashTests/Chat/NewPublicGroupModelTests.swift +++ b/FlipcashTests/Chat/NewPublicGroupModelTests.swift @@ -205,38 +205,56 @@ struct NewPublicGroupModelTests { // MARK: - Currency choice - - @Test("The form opens on Total Balance") + @Test("The form opens on Any Community Currency") func opensOnAllCurrencies() { let model = NewPublicGroupModel() - #expect(model.currency == .all) + #expect(model.currency == .anyCommunityCurrency) #expect(model.currency.mint == nil, "No token row reads as selected in the picker") } - @Test("Picking a token replaces Total Balance") + @Test("Picking a token replaces Any Community Currency") func pickingATokenReplacesAll() { let model = NewPublicGroupModel() model.select(balance: .makeTest(mint: .jeffy)) - #expect(model.currency != .all) + #expect(model.currency != .anyCommunityCurrency) #expect(model.currency.mint == .jeffy) } - @Test("Choosing Total Balance after a token clears the token") + @Test("Choosing Any Community Currency after a token clears the token") func choosingAllClearsTheToken() { let model = NewPublicGroupModel() model.select(balance: .makeTest(mint: .jeffy)) - model.selectAllCurrencies() + model.selectAnyCommunityCurrency() - #expect(model.currency == .all) + #expect(model.currency == .anyCommunityCurrency) #expect(model.currency.mint == nil) } + @Test("The caption names the picked token") + func captionNamesTheToken() { + let model = filledModel() + + guard case .specific(let balance) = model.currency else { + Issue.record("filledModel picks a token") + return + } + #expect(model.requirementCaption == "People need this amount of \(balance.stored.name) to join this chat.") + } + + @Test("The caption says any community currency while that is picked") + func captionForAnyCommunityCurrency() { + let model = NewPublicGroupModel() + + #expect(model.requirementCaption == "People need this amount of any community currency to join this chat.") + } + // MARK: - Rules - - @Test("Total Balance maps to a requirement with no mints") + @Test("Any Community Currency maps to a requirement with no mints") func allCurrenciesRulesCarryNoMints() { let model = NewPublicGroupModel() model.select(minimumBalance: .usd(100)) @@ -259,11 +277,11 @@ struct NewPublicGroupModelTests { #expect(model.rules?.speaker.isEmpty == true) } - @Test("Switching back to Total Balance drops the mint from the rules") + @Test("Switching back to Any Community Currency drops the mint from the rules") func switchingBackToAllDropsTheMint() { let model = filledModel() - model.selectAllCurrencies() + model.selectAnyCommunityCurrency() #expect(model.rules == ConversationRules( listener: [.minimumBalance(MinimumBalanceRequirement(amount: .usd(100), mints: []))] @@ -280,7 +298,7 @@ struct NewPublicGroupModelTests { #expect(model.rules != nil) } - @Test("Create sends a Total Balance group with no mints") + @Test("Create sends a Any Community Currency group with no mints") func createSendsEmptyMints() async throws { let model = NewPublicGroupModel() model.title = "Ballers" @@ -294,6 +312,20 @@ struct NewPublicGroupModelTests { )) } + // MARK: - The currency picker - + + @Test("The picker's list drops Dollars and keeps every other balance") + func excludingUSDFDropsOnlyDollars() { + let balances: [ExchangedBalance] = [ + .makeTest(mint: .usdf, quarks: 1_000_000), + .makeTest(mint: .jeffy), + ] + + let listed = balances.excludingUSDF() + + #expect(listed.map(\.stored.mint) == [.jeffy]) + } + // MARK: - The creator's own balance - @Test("Create stays disabled while the creator is short of the bar they drew") @@ -319,7 +351,7 @@ struct NewPublicGroupModelTests { #expect(model.canCreate(session: StubHoldings(usd: 10_000), rates: noRates) == false) } - @Test("Total Balance counts every holding toward the creator's own requirement") + @Test("Any Community Currency counts every holding toward the creator's own requirement") func allCurrenciesSumsTheCreatorsHoldings() { let model = NewPublicGroupModel() model.title = "Ballers" @@ -332,7 +364,7 @@ struct NewPublicGroupModelTests { #expect(model.canCreate(session: spread, rates: noRates)) } - @Test("Total Balance stays short when the holdings added together are short") + @Test("Any Community Currency stays short when the holdings added together are short") func allCurrenciesShortWhenTheTotalIsShort() { let model = NewPublicGroupModel() model.title = "Ballers" @@ -352,14 +384,6 @@ struct NewPublicGroupModelTests { #expect(model.satisfiesOwnRules(session: spread, rates: noRates) == false) } - @Test("The Total Balance card weighs the total even while a token is picked") - func allCurrenciesCardWeighsTheTotal() { - let model = filledModel(minimum: 100) - - #expect(model.satisfiesAllCurrencies(session: StubHoldings(usd: 40, otherUSD: 60), rates: noRates)) - #expect(model.satisfiesAllCurrencies(session: StubHoldings(usd: 40, otherUSD: 59), rates: noRates) == false) - } - @Test("An incomplete requirement keeps Create disabled") func createDisabledWithoutARequirement() { let model = NewPublicGroupModel()