From 669dbd8b2c92d58c1bf612eff29d801a710719f8 Mon Sep 17 00:00:00 2001 From: he-james Date: Tue, 20 Oct 2026 14:57:42 -0500 Subject: [PATCH 1/2] feat(ios): add Professional, Casual and Ultracasual style presets Output styles gets three ready-made styles and a clearer way to add and edit them. "Start from a preset" lists Professional, Casual and Ultracasual, each with an icon and a line of its instructions; one tap adds it as a style and switches to it, and an added one shows a check. Each of the user's styles has a visible pencil to edit it, in place of the hidden swipe. "New style" is pinned under the list, and its editor starts with the three presets as chips that fill in the name and the instructions. The presets become ordinary StyleProfiles, so nothing downstream knows where a style came from. StylePreset (BlurtiOSCore) holds their text and the add rule (once per name, within the four-style limit), tested. Co-Authored-By: Claude Opus 5.5 --- .../BlurtiOS/Sources/StylesView.swift | 178 +++++++++++++++--- App/BlurtiOS/BlurtiOSCore/StylePreset.swift | 56 ++++++ .../BlurtiOSTests/StylePresetTests.swift | 37 ++++ App/BlurtiOS/DESIGN.md | 7 + 4 files changed, 255 insertions(+), 23 deletions(-) create mode 100644 App/BlurtiOS/BlurtiOSCore/StylePreset.swift create mode 100644 App/BlurtiOS/BlurtiOSTests/StylePresetTests.swift diff --git a/App/BlurtiOS/BlurtiOS/Sources/StylesView.swift b/App/BlurtiOS/BlurtiOS/Sources/StylesView.swift index d8a00b25..a31b27b4 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/StylesView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/StylesView.swift @@ -1,12 +1,15 @@ import BlurtDesign import BlurtEngine +import BlurtiOSCore import SwiftUI /// Output styles: Default (the engine's base cleanup) plus up to four named -/// profiles whose instructions ride the cleanup rewrite. The store owns the -/// JSON and the active-style rule (`StyleProfileStore`); this view binds to the -/// raw defaults slots only to redraw when they change, and writes through the -/// store, the same discipline as the Mac's style row. +/// profiles whose instructions ride the cleanup rewrite. Three ways in, each +/// one tap: use a style (tap its row), edit one (its pencil), or add one, from +/// a preset (`StylePreset`) or from scratch. +/// The store owns the JSON and the active-style rule (`StyleProfileStore`); +/// this view binds to the raw defaults slots only to redraw when they change, +/// and writes through the store, the same discipline as the Mac's style row. struct StylesView: View { @AppStorage(StyleProfileStore.defaultsKey) private var profilesRaw = "" @AppStorage(StyleProfileStore.activeDefaultsKey) private var activeRaw = "" @@ -16,63 +19,144 @@ struct StylesView: View { private var profiles: [StyleProfile] { store.profiles(decoding: profilesRaw) } private var active: StyleProfile? { StyleProfileStore.active(in: profiles, id: activeRaw) } + private var isFull: Bool { profiles.count >= StyleProfileStore.profileLimit } var body: some View { List { Section { StyleRow( name: StyleProfileStore.defaultStyleName, detail: "Filler out, punctuation fixed, your words kept.", - isActive: active == nil + isActive: active == nil, edit: nil ) { store.activateDefault() } ForEach(profiles) { profile in - StyleRow(name: profile.name, detail: profile.instructions, isActive: active?.id == profile.id) { - store.activate(profile) - } - .swipeActions { - Button("Edit") { editing = profile }.tint(BlurtBrand.accent) - } + StyleRow( + name: profile.name, detail: profile.instructions, isActive: active?.id == profile.id, + edit: { editing = profile }, activate: { store.activate(profile) }) } } header: { - Eyebrow("Styles") + Eyebrow("Your styles") } footer: { - Text("Tap a style to use it. Swipe a style to edit it. Up to \(StyleProfileStore.profileLimit).") + Text("Tap a style to use it, or the pencil to change it.") } Section { - Button("Add style") { adding = true } - .disabled(profiles.count >= StyleProfileStore.profileLimit) + ForEach(StylePreset.allCases) { preset in + PresetRow(preset: preset, isAdded: preset.added(in: profiles) != nil, isFull: isFull) { + add(preset) + } + } + } header: { + Eyebrow("Start from a preset") + } footer: { + Text( + isFull + ? "You have \(StyleProfileStore.profileLimit) styles, the most there can be. Delete one to add another." + : "Adds it to your styles and switches to it. Edit it any time.") } } .brandForm() + // Pinned under the list, always in reach, rather than a row the list's + // card shape would clip. + .safeAreaInset(edge: .bottom) { + Button { + adding = true + } label: { + Label("New style", systemImage: "plus") + } + .buttonStyle(BrandButtonStyle()) + .disabled(isFull) + .padding(DesignTokens.Metrics.appPagePad) + .background(BlurtBrand.page) + } .navigationTitle("Output styles") .sheet(isPresented: $adding) { StyleEditor(profile: nil, store: store) } .sheet(item: $editing) { StyleEditor(profile: $0, store: store) } } + + /// One tap: the preset joins the user's styles (unless it already did) and + /// becomes the active one. + private func add(_ preset: StylePreset) { + guard let result = preset.adding(to: store.profiles) else { return } + store.profiles = result.profiles + store.activate(result.style) + } } +/// A style: tap to use it; its pencil (a style of the user's own) edits it. private struct StyleRow: View { let name: String let detail: String let isActive: Bool + let edit: (() -> Void)? let activate: () -> Void var body: some View { - Button(action: activate) { - HStack { + HStack(spacing: DesignTokens.Metrics.appStackGap) { + Button(action: activate) { + HStack { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appLineGap) { + Text(name).foregroundStyle(BlurtBrand.text) + Text(detail).font(BlurtType.body(DesignTokens.Typography.sizeCaption)).foregroundStyle(BlurtBrand.muted) + .lineLimit(2) // literal-ok: two lines of instructions + } + Spacer() + if isActive { Image(systemName: "checkmark").foregroundStyle(BlurtBrand.accent) } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(isActive ? .isSelected : []) + if let edit { + Button(action: edit) { + Image(systemName: "pencil") + .font(.system(size: DesignTokens.Typography.sizeGlyph, weight: DesignTokens.Typography.weightGlyph)) + .foregroundStyle(BlurtBrand.accent) + } + .buttonStyle(.borderless) + .accessibilityLabel("Edit \(name)") + } + } + } +} + +/// A preset: its icon, name and a line of what it does; tap to add it. +private struct PresetRow: View { + let preset: StylePreset + let isAdded: Bool + let isFull: Bool + let add: () -> Void + + var body: some View { + Button(action: add) { + HStack(spacing: DesignTokens.Metrics.appStackGap) { + Image(systemName: preset.symbol) + .font(.system(size: DesignTokens.Typography.sizeGlyph, weight: DesignTokens.Typography.weightGlyph)) + .foregroundStyle(BlurtBrand.accent) + .frame(width: DesignTokens.Metrics.appIcon) VStack(alignment: .leading, spacing: DesignTokens.Metrics.appLineGap) { - Text(name).foregroundStyle(BlurtBrand.text) - Text(detail).font(BlurtType.body(DesignTokens.Typography.sizeCaption)).foregroundStyle(BlurtBrand.muted) - .lineLimit(2) // literal-ok: two lines of instructions + Text(preset.name).foregroundStyle(BlurtBrand.text) + Text(preset.instructions).font(BlurtType.body(DesignTokens.Typography.sizeCaption)) + .foregroundStyle(BlurtBrand.muted) + .lineLimit(1) // literal-ok: a line of the preset's instructions } Spacer() - if isActive { Image(systemName: "checkmark").foregroundStyle(BlurtBrand.accent) } + if isAdded { + Label("Added", systemImage: "checkmark").labelStyle(.iconOnly).foregroundStyle(BlurtBrand.accent) + } else { + Eyebrow("Add", color: BlurtBrand.accent) + } } + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .disabled(!isAdded && isFull) + .accessibilityLabel(isAdded ? "\(preset.name), added" : "Add \(preset.name)") } } /// Name plus instructions, with the same caps the Mac's editor enforces: 24 /// characters of name, and instructions trimmed to the bytes the API's -/// instruction cap leaves once the base cleanup text has taken its share. +/// instruction cap leaves once the base cleanup text has taken its share. A +/// new style can start from a preset, which fills both fields to edit. private struct StyleEditor: View { let profile: StyleProfile? let store: StyleProfileStore @@ -95,6 +179,23 @@ private struct StyleEditor: View { var body: some View { NavigationStack { Form { + if profile == nil { + Section { + // The three share the row in equal widths, so none runs off it. + HStack(spacing: DesignTokens.Metrics.appChipGap) { + ForEach(StylePreset.allCases) { preset in + PresetChip(preset: preset, isChosen: instructions == preset.instructions) { + name = preset.name + instructions = preset.instructions + } + } + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + } header: { + Eyebrow("Start from") + } + } Section { TextField("e.g. Casual", text: $name) .onChange(of: name) { _, value in @@ -115,7 +216,7 @@ private struct StyleEditor: View { } header: { Eyebrow("Instructions") } footer: { - Text("\(instructions.utf8.count)/\(StyleProfileStore.characterLimit)") + Text("Added on top of the built-in cleanup. \(instructions.utf8.count)/\(StyleProfileStore.characterLimit)") } if profile != nil { Section { @@ -128,6 +229,7 @@ private struct StyleEditor: View { } .brandForm() .navigationTitle(profile == nil ? "New style" : "Edit style") + .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Save") { save() }.disabled(!canSave) } @@ -150,3 +252,33 @@ private struct StyleEditor: View { dismiss() } } + +/// A preset as a chip in the editor: its icon and name, filled with the accent +/// while the fields hold its text. +private struct PresetChip: View { + let preset: StylePreset + let isChosen: Bool + let choose: () -> Void + + var body: some View { + Button(action: choose) { + HStack(spacing: DesignTokens.Metrics.appLineGap) { + Image(systemName: preset.symbol) + Text(preset.name).lineLimit(1) + } + .font(BlurtType.body(DesignTokens.Typography.sizeCaption, bold: true)) + .padding(.vertical, DesignTokens.Metrics.appChipPadY) + .frame(maxWidth: .infinity) + .foregroundStyle(isChosen ? BlurtBrand.ctaText : BlurtBrand.text) + .background( + isChosen ? BlurtBrand.cta : BlurtBrand.cardFill, + in: RoundedRectangle(cornerRadius: DesignTokens.Metrics.radiusButton) + ) + .overlay( + RoundedRectangle(cornerRadius: DesignTokens.Metrics.radiusButton) + .strokeBorder(BlurtBrand.cardBorder, lineWidth: DesignTokens.Metrics.cardBorder)) + } + .buttonStyle(.plain) + .accessibilityAddTraits(isChosen ? .isSelected : []) + } +} diff --git a/App/BlurtiOS/BlurtiOSCore/StylePreset.swift b/App/BlurtiOS/BlurtiOSCore/StylePreset.swift new file mode 100644 index 00000000..c06cd886 --- /dev/null +++ b/App/BlurtiOS/BlurtiOSCore/StylePreset.swift @@ -0,0 +1,56 @@ +import BlurtEngine +import Foundation + +/// Ready-made output styles (Professional, Casual, Ultracasual): a starting +/// point a user adds in one tap and can then edit like any style of their own. Each +/// becomes an ordinary `StyleProfile` (its instructions ride the cleanup +/// rewrite on top of the base instruction), so nothing downstream knows it +/// began as a preset. +/// +/// The wording is a starting point, not eval-scored like the base instruction; +/// run it through `evals/dictation-prompt` before treating it as tuned. +package nonisolated enum StylePreset: String, CaseIterable, Identifiable, Sendable { + case professional = "Professional" + case casual = "Casual" + case ultracasual = "Ultracasual" + + package var id: String { rawValue } + package var name: String { rawValue } + + package var symbol: String { + switch self { + case .professional: "briefcase" + case .casual: "cup.and.saucer" + case .ultracasual: "face.smiling" + } + } + + package var instructions: String { + switch self { + case .professional: + "Use a clear, polished, professional tone suitable for work email. Write complete sentences " + + "with standard capitalization and punctuation. No slang and no emojis." + case .casual: + "Use a relaxed, friendly tone, like a message to a coworker you get along with. Contractions " + + "are fine. Keep standard capitalization and punctuation. No emojis." + case .ultracasual: + "Write like a quick text to a close friend: all lowercase, light punctuation, and no period " + + "at the end. Keep it short and natural. An emoji is fine when it fits." + } + } + + /// The user's style this preset already became, if any: matched by name, + /// whatever its instructions have been edited to since. + package func added(in profiles: [StyleProfile]) -> StyleProfile? { + profiles.first { $0.name.compare(name, options: .caseInsensitive) == .orderedSame } + } + + /// `profiles` with this preset added at the end, and the style it became; + /// the existing one when it was already added. Nil when the list is full. + package func adding(to profiles: [StyleProfile]) -> (profiles: [StyleProfile], style: StyleProfile)? { + if let existing = added(in: profiles) { return (profiles, existing) } + guard profiles.count < StyleProfileStore.profileLimit else { return nil } + let style = StyleProfile(name: name, instructions: instructions) + return (profiles + [style], style) + } +} diff --git a/App/BlurtiOS/BlurtiOSTests/StylePresetTests.swift b/App/BlurtiOS/BlurtiOSTests/StylePresetTests.swift new file mode 100644 index 00000000..8610faad --- /dev/null +++ b/App/BlurtiOS/BlurtiOSTests/StylePresetTests.swift @@ -0,0 +1,37 @@ +import BlurtEngine +import Testing + +@testable import BlurtiOSCore + +@Suite("Style presets") +struct StylePresetTests { + @Test("the three presets fit the store's caps") + func fitTheCaps() { + #expect(StylePreset.allCases.map(\.name) == ["Professional", "Casual", "Ultracasual"]) + for preset in StylePreset.allCases { + #expect(preset.name.count <= StyleProfileStore.nameLimit) + #expect(preset.instructions.utf8.count <= StyleProfileStore.characterLimit) + } + } + + @Test("adding a preset appends it once; an added one is found by name, whatever its text became") + func adding() throws { + let added = try #require(StylePreset.casual.adding(to: [])) + #expect(added.profiles.map(\.name) == ["Casual"]) + #expect(added.style.instructions == StylePreset.casual.instructions) + var edited = added.style + edited.instructions = "my own words" + let again = try #require(StylePreset.casual.adding(to: [edited])) + #expect(again.profiles == [edited]) + #expect(again.style == edited) + #expect(StylePreset.casual.added(in: [StyleProfile(name: "casual", instructions: "x")]) != nil) + } + + @Test("a full list takes no new preset, but still finds one already added") + func full() { + let full = (0.. Date: Tue, 20 Oct 2026 15:06:48 -0500 Subject: [PATCH 2/2] feat(ios): allow up to 100 output styles on iPhone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The style limit was a fixed four, sized for the Mac main window's five-button switcher, and the store trimmed every list to it on read and write. HostIdentity gains styleProfileLimit, defaulting to that four, and StyleProfileStore.limit reads it, so every store the engine builds (the transcriber's and the session's included) honours the host's cap. The iPhone, whose styles are a scrolling list, configures 100. The Mac sets nothing and keeps four, its switcher and ⌘1–⌘5 unchanged. Co-Authored-By: Claude Opus 5.5 --- .../BlurtiOS/Sources/BlurtiOSApp.swift | 5 +++- .../BlurtiOS/Sources/StylesView.swift | 9 ++++---- App/BlurtiOS/BlurtiOSCore/StylePreset.swift | 2 +- .../BlurtiOSTests/StylePresetTests.swift | 6 +++-- .../Config/StyleProfileStore.swift | 23 ++++++++++++------- Sources/BlurtEngine/HostIdentity.swift | 15 ++++++++++-- .../BlurtEngineTests/HostIdentityTests.swift | 11 +++++++++ .../StyleProfileStoreTests.swift | 9 ++++++++ 8 files changed, 62 insertions(+), 18 deletions(-) diff --git a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift index bf26f6f6..a1c3ccf2 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift @@ -20,7 +20,10 @@ struct BlurtiOSApp: App { subsystem: "com.assemblyai.blurt.iphone", keychainService: "blurt-ios", defaultsPrefix: "Blurt", - logDirectoryName: "Blurt")) + logDirectoryName: "Blurt", + // Styles live in a scrolling list here, not the Mac's five-button + // switcher, so the phone allows far more than its four. + styleProfileLimit: 100)) // The unit tests are hosted by this app: they get a coordinator that // touches neither the Keychain nor the App Group (the Mac's UITestSupport // does the same for its harness), and `start()` below never runs. diff --git a/App/BlurtiOS/BlurtiOS/Sources/StylesView.swift b/App/BlurtiOS/BlurtiOS/Sources/StylesView.swift index a31b27b4..511b8191 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/StylesView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/StylesView.swift @@ -3,8 +3,9 @@ import BlurtEngine import BlurtiOSCore import SwiftUI -/// Output styles: Default (the engine's base cleanup) plus up to four named -/// profiles whose instructions ride the cleanup rewrite. Three ways in, each +/// Output styles: Default (the engine's base cleanup) plus the user's named +/// profiles (up to `StyleProfileStore.limit`, 100 on the phone) whose +/// instructions ride the cleanup rewrite. Three ways in, each /// one tap: use a style (tap its row), edit one (its pencil), or add one, from /// a preset (`StylePreset`) or from scratch. /// The store owns the JSON and the active-style rule (`StyleProfileStore`); @@ -19,7 +20,7 @@ struct StylesView: View { private var profiles: [StyleProfile] { store.profiles(decoding: profilesRaw) } private var active: StyleProfile? { StyleProfileStore.active(in: profiles, id: activeRaw) } - private var isFull: Bool { profiles.count >= StyleProfileStore.profileLimit } + private var isFull: Bool { profiles.count >= StyleProfileStore.limit } var body: some View { List { @@ -49,7 +50,7 @@ struct StylesView: View { } footer: { Text( isFull - ? "You have \(StyleProfileStore.profileLimit) styles, the most there can be. Delete one to add another." + ? "You have \(StyleProfileStore.limit) styles, the most there can be. Delete one to add another." : "Adds it to your styles and switches to it. Edit it any time.") } } diff --git a/App/BlurtiOS/BlurtiOSCore/StylePreset.swift b/App/BlurtiOS/BlurtiOSCore/StylePreset.swift index c06cd886..68ad9597 100644 --- a/App/BlurtiOS/BlurtiOSCore/StylePreset.swift +++ b/App/BlurtiOS/BlurtiOSCore/StylePreset.swift @@ -49,7 +49,7 @@ package nonisolated enum StylePreset: String, CaseIterable, Identifiable, Sendab /// the existing one when it was already added. Nil when the list is full. package func adding(to profiles: [StyleProfile]) -> (profiles: [StyleProfile], style: StyleProfile)? { if let existing = added(in: profiles) { return (profiles, existing) } - guard profiles.count < StyleProfileStore.profileLimit else { return nil } + guard profiles.count < StyleProfileStore.limit else { return nil } let style = StyleProfile(name: name, instructions: instructions) return (profiles + [style], style) } diff --git a/App/BlurtiOS/BlurtiOSTests/StylePresetTests.swift b/App/BlurtiOS/BlurtiOSTests/StylePresetTests.swift index 8610faad..c5533885 100644 --- a/App/BlurtiOS/BlurtiOSTests/StylePresetTests.swift +++ b/App/BlurtiOS/BlurtiOSTests/StylePresetTests.swift @@ -5,8 +5,10 @@ import Testing @Suite("Style presets") struct StylePresetTests { - @Test("the three presets fit the store's caps") + @Test("the phone allows 100 styles, and the three presets fit the store's caps") func fitTheCaps() { + // The test host is the app, which configures the phone's limit at launch. + #expect(StyleProfileStore.limit == 100) #expect(StylePreset.allCases.map(\.name) == ["Professional", "Casual", "Ultracasual"]) for preset in StylePreset.allCases { #expect(preset.name.count <= StyleProfileStore.nameLimit) @@ -29,7 +31,7 @@ struct StylePresetTests { @Test("a full list takes no new preset, but still finds one already added") func full() { - let full = (0.. [StyleProfile] { - profiles.prefix(profileLimit).map { profile in + static func normalized(_ profiles: [StyleProfile], limit: Int = limit) -> [StyleProfile] { + profiles.prefix(limit).map { profile in StyleProfile( id: profile.id, name: String((profile.name.trimmedNonEmpty() ?? fallbackName).prefix(nameLimit)), diff --git a/Sources/BlurtEngine/HostIdentity.swift b/Sources/BlurtEngine/HostIdentity.swift index ef3be51e..dc4ef387 100644 --- a/Sources/BlurtEngine/HostIdentity.swift +++ b/Sources/BlurtEngine/HostIdentity.swift @@ -76,18 +76,28 @@ public struct HostIdentity: Sendable, Equatable { /// logs are written to (`~/Library/Logs/Blurt/{dictations,errors}.jsonl`). public let logDirectoryName: String + // periphery:ignore - public for hosts outside this repo (the iOS app); the Mac app keeps the default. + /// How many style profiles the user may define (`StyleProfileStore.limit`). + /// Blurt's is `StyleProfileStore.profileLimit`, the most its main window's + /// switcher holds; a host whose styles live in a scrolling list can allow + /// more. Every store reads it, so the list the transcriber resolves the active + /// style from is never trimmed shorter than the one the user sees. + public let styleProfileLimit: Int + public init( productName: String, subsystem: String, keychainService: String, defaultsPrefix: String, - logDirectoryName: String + logDirectoryName: String, + styleProfileLimit: Int = StyleProfileStore.profileLimit ) { self.productName = productName self.subsystem = subsystem self.keychainService = keychainService self.defaultsPrefix = defaultsPrefix self.logDirectoryName = logDirectoryName + self.styleProfileLimit = max(1, styleProfileLimit) } /// Blurt's own identity, and the value an unconfigured host inherits — so the @@ -127,7 +137,8 @@ public struct HostIdentity: Sendable, Equatable { subsystem: subsystem, keychainService: service, defaultsPrefix: defaultsPrefix, - logDirectoryName: logDirectoryName) + logDirectoryName: logDirectoryName, + styleProfileLimit: styleProfileLimit) } /// The identity every engine component reads. `.blurt` until a host calls diff --git a/Tests/BlurtEngineTests/HostIdentityTests.swift b/Tests/BlurtEngineTests/HostIdentityTests.swift index ee74d62a..b74585fe 100644 --- a/Tests/BlurtEngineTests/HostIdentityTests.swift +++ b/Tests/BlurtEngineTests/HostIdentityTests.swift @@ -65,6 +65,17 @@ struct HostIdentityTests { #expect(HostIdentity.current == .blurt) } + @Test("a host's style limit defaults to Blurt's, and is never below one") + func styleProfileLimit() { + let identity = HostIdentity( + productName: "Host", subsystem: "host", keychainService: "host", defaultsPrefix: "Host", logDirectoryName: "Host") + #expect(identity.styleProfileLimit == StyleProfileStore.profileLimit) + let none = HostIdentity( + productName: "Host", subsystem: "host", keychainService: "host", defaultsPrefix: "Host", logDirectoryName: "Host", + styleProfileLimit: 0) + #expect(none.styleProfileLimit == 1) + } + @Test("defaults keys are the prefix plus the case's raw value") func defaultsKeysComposeFromThePrefix() { // Blurt's prefix reproduces the keys already on disk — renaming one abandons diff --git a/Tests/BlurtEngineTests/StyleProfileStoreTests.swift b/Tests/BlurtEngineTests/StyleProfileStoreTests.swift index 7c3773ae..c979fbf5 100644 --- a/Tests/BlurtEngineTests/StyleProfileStoreTests.swift +++ b/Tests/BlurtEngineTests/StyleProfileStoreTests.swift @@ -97,6 +97,15 @@ struct StyleProfileStoreTests { #expect(store.profiles.last?.name == "Style \(StyleProfileStore.profileLimit)") } + @Test("a host's own limit keeps more profiles than Blurt's four; Blurt's stays four") + func hostLimitSetsTheCap() { + let ten = (1...10).map { StyleProfile(name: "Style \($0)", instructions: "instruction \($0)") } + #expect(StyleProfileStore.normalized(ten, limit: 100).count == 10) + #expect(StyleProfileStore.normalized(ten, limit: StyleProfileStore.profileLimit).count == 4) + #expect(HostIdentity.blurt.styleProfileLimit == StyleProfileStore.profileLimit) + #expect(StyleProfileStore.profileLimit == 4) + } + /// The cap is per profile because only one profile is ever sent. Both are /// trimmed to the full budget even though their sum is far over it — a sum that /// would matter only if they were concatenated, which is precisely what this