cat swift/milestone-interfaccia-swiftui-cli.md

Lezione 7870 min

Milestone 6: interfaccia SwiftUI o CLI

Aggiungere una consegna visibile al progetto finale: piccola app SwiftUI o CLI ergonomica costruita sopra lo stesso nucleo testabile.

Il progetto finale deve avere una superficie usabile.

Puoi scegliere:

  • piccola app SwiftUI;
  • CLI;
  • endpoint Vapor;
  • SDK con esempio eseguibile.

In questa milestone ci concentriamo sulle due opzioni più pratiche: SwiftUI e CLI.

Regola principale

L’interfaccia non deve contenere il dominio.

Deve:

  • raccogliere input;
  • chiamare use case;
  • mostrare stato;
  • tradurre errori;
  • gestire lifecycle o exit code.

Non deve:

  • costruire URL;
  • decodificare JSON remoto;
  • duplicare regole;
  • decidere invarianti;
  • accedere direttamente a cache interna.

Opzione A: SwiftUI

Stato:

public enum LoadingState<Value: Equatable>: Equatable {
  case idle
  case loading
  case loaded(Value)
  case failed(String)
}

View model:

@MainActor
@Observable
final class StoryListViewModel {
  private let searchStories: SearchStoriesUseCase

  var query = ""
  var state: LoadingState<[Story]> = .idle

  init(searchStories: SearchStoriesUseCase) {
    self.searchStories = searchStories
  }

  func search() async {
    do {
      state = .loading
      let searchQuery = try SearchQuery(query)
      let stories = try await searchStories.execute(query: searchQuery)
      state = .loaded(stories)
    } catch {
      state = .failed(Self.message(for: error))
    }
  }

  private static func message(for error: Error) -> String {
    switch error {
    case SearchError.queryTooShort:
      return "Inserisci almeno due caratteri."
    default:
      return "Impossibile caricare i risultati."
    }
  }
}

La view model è il punto testabile della UI.

SwiftUI screen

struct StorySearchScreen: View {
  @State private var viewModel: StoryListViewModel

  init(viewModel: StoryListViewModel) {
    self._viewModel = State(initialValue: viewModel)
  }

  var body: some View {
    NavigationStack {
      List {
        Section {
          TextField("Cerca", text: $viewModel.query)
            .textInputAutocapitalization(.never)

          Button("Cerca") {
            Task {
              await viewModel.search()
            }
          }
        }

        content
      }
      .navigationTitle("Stories")
    }
  }

  @ViewBuilder
  private var content: some View {
    switch viewModel.state {
    case .idle:
      EmptyView()
    case .loading:
      ProgressView()
    case .loaded(let stories):
      ForEach(stories) { story in
        Text(story.title)
      }
    case .failed(let message):
      Text(message)
    }
  }
}

Per il capstone basta una UI piccola ma coerente.

SwiftUI composition

extension StoryListViewModel {
  static func live() -> StoryListViewModel {
    let client = URLSessionHTTPClient()
    let repository = RemoteStoryRepository(
      client: client,
      requestBuilder: RequestBuilder(baseURL: URL(string: "https://hn.algolia.com")!)
    )

    return StoryListViewModel(
      searchStories: SearchStoriesUseCase(repository: repository)
    )
  }
}

In un progetto più grande, sposta questa factory in un composition root.

Test view model

@MainActor
@Test
func viewModelMostraRisultati() async throws {
  let viewModel = StoryListViewModel(
    searchStories: SearchStoriesUseCase(
      repository: InMemoryStoryRepository(stories: [.sample])
    )
  )
  viewModel.query = "swift"

  await viewModel.search()

  #expect(viewModel.state == .loaded([.sample]))
}

La view grafica può restare fuori dalla test suite del capstone, se il view model è ben coperto.

Opzione B: CLI

CLI con ArgumentParser:

@main
struct StoryCommand: AsyncParsableCommand {
  static let configuration = CommandConfiguration(
    commandName: "story",
    subcommands: [Search.self]
  )
}

Search:

struct Search: AsyncParsableCommand {
  @Argument(help: "Termine di ricerca.")
  var query: String

  @Flag(help: "Stampa output JSON.")
  var json = false

  mutating func run() async throws {
    let environment = try CLIEnvironment.live()
    let stories = try await environment.search.execute(
      query: try SearchQuery(query)
    )

    if json {
      try environment.output.writeJSON(stories)
    } else {
      stories.forEach { environment.output.write($0.title) }
    }
  }
}

La CLI deve essere comoda anche in automazioni.

OutputWriter

protocol OutputWriter: Sendable {
  func write(_ line: String)
}

struct ConsoleOutputWriter: OutputWriter {
  func write(_ line: String) {
    print(line)
  }
}

Per output JSON:

extension OutputWriter {
  func writeJSON<T: Encodable>(_ value: T) throws {
    let data = try JSONEncoder().encode(value)
    write(String(decoding: data, as: UTF8.self))
  }
}

Errori CLI

Traduci errori in messaggi.

catch SearchError.queryTooShort {
  throw ValidationError("La query deve avere almeno due caratteri.")
}

Una CLI deve fallire in modo comprensibile.

Checklist

L’interfaccia è completa quando:

  • usa use case, non infrastruttura diretta;
  • mostra loading/success/error;
  • traduce errori;
  • è testabile tramite view model o command wrapper;
  • ha una demo chiara;
  • non duplica regole di dominio;
  • supporta cancellation se usa SwiftUI task;
  • ha output stabile se è CLI.

Esercizio

Scegli una superficie:

SwiftUI

  1. view model @MainActor;
  2. stato idle/loading/loaded/failed;
  3. schermata con input e risultati;
  4. factory live;
  5. test view model.

CLI

  1. executable target;
  2. comando principale;
  3. almeno un subcommand;
  4. output plain text;
  5. opzione --json;
  6. test output.

Obiettivo: il progetto deve essere dimostrabile in meno di un minuto.