From 81e2c27532de1fee9f2bf74925b48b4a69b6db4a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 14:59:45 +0000 Subject: [PATCH 1/3] feat(mensa): parse the Studierendenwerk Oldenburg menu Reads counters, meals, the student price and icon notes from the HTML of the Studierendenwerk menu system and recognises closed days. The tests use an excerpt of a real answer for Mensa Wechloy. Refs #67 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- Data/Mensa/StudierendenwerkMenuParser.swift | 103 +++++++++++++++ StudyAppTests/StudierendenwerkMenuTests.swift | 120 ++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 Data/Mensa/StudierendenwerkMenuParser.swift create mode 100644 StudyAppTests/StudierendenwerkMenuTests.swift diff --git a/Data/Mensa/StudierendenwerkMenuParser.swift b/Data/Mensa/StudierendenwerkMenuParser.swift new file mode 100644 index 0000000..a7494c6 --- /dev/null +++ b/Data/Mensa/StudierendenwerkMenuParser.swift @@ -0,0 +1,103 @@ +import Foundation + +/// Reads the menu HTML of the Studierendenwerk Oldenburg (`func=make_spl` of its menu system). +/// +/// The page lists a heading per serving counter (`.gruppenname`, e.g. „AUSGABE EINS“, „SUPPEN“) +/// followed by its meals (`.splMeal`). Each meal shows its name, the prices +/// „Studierende / Bedienstete / Gäste“ and icons such as „vegan“. A closed day is a single `.nodata` row. +enum StudierendenwerkMenuParser { + /// Heading for meals listed before the first counter heading. + static let defaultCategory = "Gerichte" + + static func isClosed(_ html: String) -> Bool { + html.contains("class='row nodata'") || html.contains("class=\"row nodata\"") + } + + static func meals(_ html: String) -> [Meal] { + var meals: [Meal] = [] + var category = defaultCategory + // Headings and meals in page order; each meal's HTML runs up to the next marker. + let markers = matches(of: #"col-xs-4 gruppenname'>([^<]*)<|
String? { + guard let raw = matches(of: #"
(.*?)"#, in: block).first?.group(1), + let amount = matches(of: #"(\d+),(\d{2})"#, in: prices).first, + let euros = amount.group(1), let cents = amount.group(2) else { return nil } + return Double("\(euros).\(cents)") + } + + /// Icon titles such as „vegan“ or „mit Rindfleisch“, each once. + static func notes(in block: String) -> [String] { + var notes: [String] = [] + for match in matches(of: #"class='iconLarge' title='([^']*)'"#, in: block) { + guard let title = match.group(1).map(plainText), !title.isEmpty, !notes.contains(title) else { continue } + notes.append(title) + } + return notes + } + + /// „AUSGABE EINS“ → „Ausgabe Eins“; an empty heading keeps the default. + static func categoryTitle(_ heading: String) -> String { + let text = plainText(heading) + return text.isEmpty ? defaultCategory : text.lowercased().capitalized + } + + // MARK: Helpers + + private static func plainText(_ html: String) -> String { + var text = html.replacingOccurrences(of: #"<[^>]+>"#, with: "", options: .regularExpression) + let entities = [" ": " ", "&": "&", "€": "€", """: "\"", "'": "'", "<": "<", ">": ">"] + for (entity, replacement) in entities { + text = text.replacingOccurrences(of: entity, with: replacement) + } + return text.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private struct Match { + let range: Range + let groups: [String?] + + func group(_ index: Int) -> String? { index <= groups.count ? groups[index - 1] : nil } + } + + private static func matches(of pattern: String, in text: String) -> [Match] { + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]) else { return [] } + return regex.matches(in: text, range: NSRange(text.startIndex..., in: text)).compactMap { result in + guard let range = Range(result.range, in: text) else { return nil } + let groups = (1..
+
+
PREIS STUDIERENDE | BEDIENSTETE | GÄSTE
+
+
+
+
+ +
+ Zwiebelquiche
(Mi, We)
+
 
+
+ +
+ + +
+
+ Zwiebelquiche
(Mi, We)
+
+
3,30 / 5,20 / 5,40 €
+
icon
+
+ +
+

icon
+
+
+
PREIS STUDIERENDE | BEDIENSTETE | GÄSTE
+
3,30 / 5,20 / 5,40 €
+
+ +
+
 
+
+
SUPPEN
+
PREIS STUDIERENDE | BEDIENSTETE | GÄSTE
+
+
+
SUPPEN
+
+ +
+ Mexikanische Maiscremesuppe
(2, Kn, Sl)
+
 
+
+ +
+ + +
+
+ Mexikanische Maiscremesuppe
(2, Kn, Sl)
+
+
0,60 / 1,25 / 1,35 €
+
icon
+
+ +
+

icon
+
+
+
PREIS STUDIERENDE | BEDIENSTETE | GÄSTE
+
0,60 / 1,25 / 1,35 €
+
+ +
+
 
+
+ """# + + @Test func readsMealsWithCategoryPriceAndNotes() { + let meals = StudierendenwerkMenuParser.meals(Self.wechloy) + + #expect(meals.map(\.name) == ["Zwiebelquiche", "Mexikanische Maiscremesuppe"]) + #expect(meals.map(\.category) == ["Gerichte", "Suppen"]) + #expect(meals.map(\.studentPrice) == [3.3, 0.6]) + #expect(meals.map(\.id) == [0, 1]) + #expect(!StudierendenwerkMenuParser.isClosed(Self.wechloy)) + } + + @Test func readsCounterHeadingsMaximumPricesAndSeveralIcons() { + let html = """ +
AUSGABE DREI
+
+
+ Chili con Carne, Fladenbrot
(Kn, We)
+
max. 2,50* / 5,00 / 5,20 €
+
icon\ + icon
+ icon
+
+ """ + + let meal = StudierendenwerkMenuParser.meals(html).first + + #expect(meal?.name == "Chili con Carne, Fladenbrot") + #expect(meal?.category == "Ausgabe Drei") + #expect(meal?.studentPrice == 2.5) + #expect(meal?.notes == ["mit Rindfleisch"]) + } + + @Test func recognisesClosedDays() { + let html = """ +
+
Die Mensa hat an diesem Tag geschlossen.
+ """ + + #expect(StudierendenwerkMenuParser.isClosed(html)) + #expect(StudierendenwerkMenuParser.meals(html).isEmpty) + } +} From 6aff1c495a84726914e86a1e2589148ae7ad89b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 14:59:45 +0000 Subject: [PATCH 2/3] feat(mensa): load menus from the Studierendenwerk Oldenburg The new StudierendenwerkOldenburgClient replaces the OpenMensa adapter behind the CanteenService port. The port now lists the offered canteens instead of searching a radius, so the settings show only Mensa Uhlhornsweg and Mensa Wechloy. A canteen chosen from OpenMensa before is replaced by Mensa Uhlhornsweg. Refs #67 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- App/AppContainer.swift | 2 +- Data/Mensa/OpenMensaClient.swift | 45 ------------ Data/Mensa/OpenMensaParser.swift | 55 --------------- .../StudierendenwerkOldenburgClient.swift | 70 +++++++++++++++++++ Domain/Entities/Mensa.swift | 2 +- Domain/Repositories/CanteenService.swift | 6 +- Domain/Repositories/StudyPreferences.swift | 2 +- Domain/UseCases/MensaUseCases.swift | 8 --- Presentation/Mensa/MensaCard.swift | 2 +- Presentation/Mensa/MensaViewModel.swift | 16 +++-- StudyAppTests/MensaTests.swift | 52 ++++++++------ 11 files changed, 117 insertions(+), 143 deletions(-) delete mode 100644 Data/Mensa/OpenMensaClient.swift delete mode 100644 Data/Mensa/OpenMensaParser.swift create mode 100644 Data/Mensa/StudierendenwerkOldenburgClient.swift diff --git a/App/AppContainer.swift b/App/AppContainer.swift index e6ffbb4..fa33554 100644 --- a/App/AppContainer.swift +++ b/App/AppContainer.swift @@ -56,7 +56,7 @@ final class AppContainer { studIPService = studIPClient studIPAnnouncementService = studIPClient studIPScheduleService = studIPClient - canteenService = OpenMensaClient() + canteenService = StudierendenwerkOldenburgClient() if inMemory { preferences = InMemoryStudyPreferences() studIPAccountStore = InMemoryStudIPAccountStore() diff --git a/Data/Mensa/OpenMensaClient.swift b/Data/Mensa/OpenMensaClient.swift deleted file mode 100644 index c0c359d..0000000 --- a/Data/Mensa/OpenMensaClient.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation - -/// Loads canteens and menus from the OpenMensa API v2. No account is needed. -struct OpenMensaClient: CanteenService { - static let baseURL = URL(string: "https://openmensa.org/api/v2/")! - - var session: URLSession = .shared - var calendar: Calendar = .current - - func canteens(nearLatitude latitude: Double, longitude: Double, radius: Double) async throws -> [Canteen] { - let data = try await get("canteens", query: [ - URLQueryItem(name: "near[lat]", value: String(latitude)), - URLQueryItem(name: "near[lng]", value: String(longitude)), - URLQueryItem(name: "near[dist]", value: String(radius)), - ]) - return try OpenMensaParser.canteens(data) - } - - func menu(canteenID: Int, on date: Date) async throws -> CanteenMenu { - let day = OpenMensaParser.dayString(date, calendar: calendar) - let isClosed = try OpenMensaParser.isClosed(try await get("canteens/\(canteenID)/days/\(day)")) - let meals = isClosed ? [] : try OpenMensaParser.meals(try await get("canteens/\(canteenID)/days/\(day)/meals")) - return CanteenMenu(canteenID: canteenID, date: date, isClosed: isClosed, meals: meals) - } - - private func get(_ path: String, query: [URLQueryItem] = []) async throws -> Data { - var components = URLComponents(url: Self.baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false) - if !query.isEmpty { components?.queryItems = query } - guard let url = components?.url else { throw MensaError.unavailable } - var request = URLRequest(url: url) - request.timeoutInterval = 20 - request.setValue("application/json", forHTTPHeaderField: "Accept") - let data: Data - let response: URLResponse - do { - (data, response) = try await session.data(for: request) - } catch { - throw MensaError.offline - } - guard let http = response as? HTTPURLResponse else { throw MensaError.unavailable } - // OpenMensa answers 404 for days without a published menu. - guard (200...299).contains(http.statusCode) else { throw MensaError.unavailable } - return data - } -} diff --git a/Data/Mensa/OpenMensaParser.swift b/Data/Mensa/OpenMensaParser.swift deleted file mode 100644 index 43ced60..0000000 --- a/Data/Mensa/OpenMensaParser.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation - -/// Reads OpenMensa API v2 responses (https://openmensa.org/api/v2). -enum OpenMensaParser { - private struct CanteenDTO: Decodable { - let id: Int - let name: String - let city: String? - let address: String? - } - - private struct DayDTO: Decodable { - let closed: Bool - } - - private struct MealDTO: Decodable { - struct Prices: Decodable { - let students: Double? - } - - let id: Int - let name: String - let category: String? - let prices: Prices? - let notes: [String]? - } - - static func canteens(_ data: Data) throws -> [Canteen] { - try JSONDecoder().decode([CanteenDTO].self, from: data).map { - Canteen(id: $0.id, name: $0.name, city: $0.city ?? "", address: $0.address ?? "") - } - } - - static func isClosed(_ data: Data) throws -> Bool { - try JSONDecoder().decode(DayDTO.self, from: data).closed - } - - static func meals(_ data: Data) throws -> [Meal] { - try JSONDecoder().decode([MealDTO].self, from: data).map { - Meal( - id: $0.id, - name: $0.name, - category: $0.category ?? "Essen", - studentPrice: $0.prices?.students, - notes: $0.notes ?? [] - ) - } - } - - /// OpenMensa addresses days as `yyyy-MM-dd` in the canteen's (German) time zone. - static func dayString(_ date: Date, calendar: Calendar) -> String { - let components = calendar.dateComponents([.year, .month, .day], from: date) - return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) - } -} diff --git a/Data/Mensa/StudierendenwerkOldenburgClient.swift b/Data/Mensa/StudierendenwerkOldenburgClient.swift new file mode 100644 index 0000000..6792a41 --- /dev/null +++ b/Data/Mensa/StudierendenwerkOldenburgClient.swift @@ -0,0 +1,70 @@ +import Foundation + +/// Loads the menus of the University of Oldenburg's canteens from the Studierendenwerk Oldenburg. +/// +/// The Studierendenwerk's menu pages (studierendenwerk-oldenburg.de/essen/speiseplaene-mensen) embed its +/// menu system, which returns a day's menu as HTML for a `POST` with `func=make_spl`. No account is needed. +struct StudierendenwerkOldenburgClient: CanteenService { + static let endpoint = URL(string: "https://sw-oldenburg-spl56.maxmanager.xyz/inc/ajax-php_konnektor.inc.php")! + + /// The canteens of the university; the IDs are the menu system's location IDs. + static let universityCanteens = [ + Canteen(id: 2, name: "Mensa Uhlhornsweg", city: "Oldenburg", address: "Campus Haarentor"), + Canteen(id: 3, name: "Mensa Wechloy", city: "Oldenburg", address: "Campus Wechloy"), + ] + + var session: URLSession = .shared + var calendar: Calendar = .current + + func canteens() async throws -> [Canteen] { + Self.universityCanteens + } + + func menu(canteenID: Int, on date: Date) async throws -> CanteenMenu { + var request = URLRequest(url: Self.endpoint) + request.httpMethod = "POST" + request.timeoutInterval = 20 + request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type") + request.httpBody = Data(Self.formBody(locationID: canteenID, date: date, calendar: calendar).utf8) + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw MensaError.offline + } + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode), + let html = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1) + else { throw MensaError.unavailable } + let isClosed = StudierendenwerkMenuParser.isClosed(html) + let meals = isClosed ? [] : StudierendenwerkMenuParser.meals(html) + return CanteenMenu(canteenID: canteenID, date: date, isClosed: isClosed, meals: meals) + } + + /// The menu system also needs the Mondays of the day's week and of the following week. + static func formBody(locationID: Int, date: Date, calendar: Calendar) -> String { + let monday = weekStart(of: date, calendar: calendar) + let nextMonday = calendar.date(byAdding: .day, value: 7, to: monday) ?? monday + return [ + "func=make_spl", + "locId=\(locationID)", + "date=\(dayString(date, calendar: calendar))", + "lang=de", + "startThisWeek=\(dayString(monday, calendar: calendar))", + "startNextWeek=\(dayString(nextMonday, calendar: calendar))", + ].joined(separator: "&") + } + + /// Monday of the week containing `date`, also for Sundays. + static func weekStart(of date: Date, calendar: Calendar) -> Date { + var mondayFirst = calendar + mondayFirst.firstWeekday = 2 + return mondayFirst.dateInterval(of: .weekOfYear, for: date)?.start ?? calendar.startOfDay(for: date) + } + + /// `yyyy-MM-dd` in the canteen's (German) time zone. + static func dayString(_ date: Date, calendar: Calendar) -> String { + let components = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) + } +} diff --git a/Domain/Entities/Mensa.swift b/Domain/Entities/Mensa.swift index 2a6c24b..df2c788 100644 --- a/Domain/Entities/Mensa.swift +++ b/Domain/Entities/Mensa.swift @@ -1,6 +1,6 @@ import Foundation -/// A canteen listed by OpenMensa. +/// A canteen whose menu the app can show. struct Canteen: Identifiable, Hashable, Codable, Sendable { let id: Int var name: String diff --git a/Domain/Repositories/CanteenService.swift b/Domain/Repositories/CanteenService.swift index 155dd78..43914b4 100644 --- a/Domain/Repositories/CanteenService.swift +++ b/Domain/Repositories/CanteenService.swift @@ -1,10 +1,10 @@ import Foundation -/// Port to a canteen menu source (OpenMensa). +/// Port to a canteen menu source (the Studierendenwerk Oldenburg). @MainActor protocol CanteenService { - /// Canteens within `radius` kilometres of a coordinate. - func canteens(nearLatitude latitude: Double, longitude: Double, radius: Double) async throws -> [Canteen] + /// The canteens the source offers menus for, the default one first. + func canteens() async throws -> [Canteen] /// The menu of a canteen on a day. Throws `MensaError.unavailable` when the canteen publishes nothing for it. func menu(canteenID: Int, on date: Date) async throws -> CanteenMenu } diff --git a/Domain/Repositories/StudyPreferences.swift b/Domain/Repositories/StudyPreferences.swift index 7a31a52..ad15f48 100644 --- a/Domain/Repositories/StudyPreferences.swift +++ b/Domain/Repositories/StudyPreferences.swift @@ -20,7 +20,7 @@ protocol StudySettingsStore: AnyObject { var automaticBackups: Bool { get set } /// Import the dates of linked Stud.IP courses into the calendar during a Stud.IP sync. var importsStudIPTimetable: Bool { get set } - /// OpenMensa canteen shown on „Heute“; `nil` hides the menu. + /// Canteen shown on „Heute“; `nil` hides the menu. var canteenID: Int? { get set } var notificationSettings: NotificationSettings { get set } var studyTimerSettings: StudyTimerSettings { get set } diff --git a/Domain/UseCases/MensaUseCases.swift b/Domain/UseCases/MensaUseCases.swift index d3e0c7d..7a23b37 100644 --- a/Domain/UseCases/MensaUseCases.swift +++ b/Domain/UseCases/MensaUseCases.swift @@ -12,14 +12,6 @@ enum MensaSchedule { } } -/// Canteens of the Studierendenwerk Oldenburg area, nearest first. -enum MensaLocation { - /// University of Oldenburg, campus Haarentor. - static let oldenburgLatitude = 53.1478 - static let oldenburgLongitude = 8.1814 - static let searchRadius = 15.0 -} - /// Loads a menu and keeps it for the rest of the day, so „Heute“ does not ask the server on every visit. @MainActor final class LoadCanteenMenuUseCase { diff --git a/Presentation/Mensa/MensaCard.swift b/Presentation/Mensa/MensaCard.swift index 60927a1..17ce63b 100644 --- a/Presentation/Mensa/MensaCard.swift +++ b/Presentation/Mensa/MensaCard.swift @@ -106,7 +106,7 @@ struct MensaSettingsSection: View { } header: { Text("Mensa") } footer: { - Text("Der Speiseplan kommt von OpenMensa. Nach dem Mittagessen zeigt „Heute“ schon den Plan für morgen.") + Text("Der Speiseplan kommt vom Studierendenwerk Oldenburg. Nach dem Mittagessen zeigt „Heute“ schon den Plan für morgen.") } .task { await viewModel.loadCanteens() } } diff --git a/Presentation/Mensa/MensaViewModel.swift b/Presentation/Mensa/MensaViewModel.swift index 476637e..f002882 100644 --- a/Presentation/Mensa/MensaViewModel.swift +++ b/Presentation/Mensa/MensaViewModel.swift @@ -53,6 +53,7 @@ final class MensaViewModel { } func refresh() async { + await loadCanteens() guard let canteenID else { state = .off return @@ -68,16 +69,19 @@ final class MensaViewModel { } } - /// Canteens around Oldenburg for the settings, nearest first. + /// The canteens for the settings, the default one first. func loadCanteens() async { guard canteens.isEmpty, !isLoadingCanteens else { return } isLoadingCanteens = true defer { isLoadingCanteens = false } - canteens = (try? await service.canteens( - nearLatitude: MensaLocation.oldenburgLatitude, - longitude: MensaLocation.oldenburgLongitude, - radius: MensaLocation.searchRadius - )) ?? [] + canteens = (try? await service.canteens()) ?? [] + adoptSupportedCanteen() + } + + /// A canteen chosen from an earlier source (OpenMensa) is no longer offered; the default one replaces it. + private func adoptSupportedCanteen() { + guard let canteenID, let fallback = canteens.first, !canteens.contains(where: { $0.id == canteenID }) else { return } + settings.canteenID = fallback.id } static func priceText(_ price: Double?) -> String? { diff --git a/StudyAppTests/MensaTests.swift b/StudyAppTests/MensaTests.swift index 9d28e74..140bacc 100644 --- a/StudyAppTests/MensaTests.swift +++ b/StudyAppTests/MensaTests.swift @@ -2,31 +2,21 @@ import Foundation import Testing @testable import StudyApp -struct OpenMensaParserTests { - @Test func readsCanteens() throws { - let json = #"[{"id":1084,"name":"Oldenburg, Mensa Uhlhornsweg","city":"Oldenburg","address":"Uhlhornsweg 49-55, 26129 Oldenburg","coordinates":[53.14,8.18]}]"# - let canteens = try OpenMensaParser.canteens(Data(json.utf8)) - #expect(canteens == [Canteen(id: 1084, name: "Oldenburg, Mensa Uhlhornsweg", city: "Oldenburg", address: "Uhlhornsweg 49-55, 26129 Oldenburg")]) - } - - @Test func readsMealsWithStudentPriceAndNotes() throws { - let json = #""" - [{"id":1,"name":"Spaghetti Bolognese","category":"Pasta","prices":{"students":2.5,"employees":4.1,"pupils":null,"others":5.0},"notes":["Rind"]}, - {"id":2,"name":"Gemüsecurry","category":"Vegan","prices":{"students":null},"notes":[]}, - {"id":3,"name":"Salatbar","prices":null}] - """# - let meals = try OpenMensaParser.meals(Data(json.utf8)) - #expect(meals.map(\.studentPrice) == [2.5, nil, nil]) - #expect(meals.map(\.category) == ["Pasta", "Vegan", "Essen"]) - #expect(meals.first?.notes == ["Rind"]) +@MainActor +struct StudierendenwerkOldenburgClientTests { + @Test func offersTheUniversityCanteensWithUhlhornswegFirst() { + #expect(StudierendenwerkOldenburgClient.universityCanteens.map(\.name) == ["Mensa Uhlhornsweg", "Mensa Wechloy"]) + #expect(StudierendenwerkOldenburgClient.universityCanteens.map(\.id) == [2, 3]) } - @Test func readsClosedDaysAndFormatsDates() throws { - #expect(try OpenMensaParser.isClosed(Data(#"{"date":"2026-10-28","closed":true}"#.utf8))) + @Test func asksForTheDayWithTheMondaysOfItsWeekAndTheNext() throws { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = try #require(TimeZone(identifier: "Europe/Berlin")) - let date = try #require(calendar.date(from: DateComponents(year: 2026, month: 3, day: 9, hour: 23, minute: 30))) - #expect(OpenMensaParser.dayString(date, calendar: calendar) == "2026-03-09") + let sunday = try #require(calendar.date(from: DateComponents(year: 2026, month: 11, day: 1, hour: 23, minute: 30))) + + let body = StudierendenwerkOldenburgClient.formBody(locationID: 3, date: sunday, calendar: calendar) + + #expect(body == "func=make_spl&locId=3&date=2026-11-01&lang=de&startThisWeek=2026-10-26&startNextWeek=2026-11-02") } } @@ -36,7 +26,9 @@ final class FakeCanteenService: CanteenService { var error: MensaError? private(set) var requests = 0 - func canteens(nearLatitude latitude: Double, longitude: Double, radius: Double) async throws -> [Canteen] { [] } + var canteenList: [Canteen] = [] + + func canteens() async throws -> [Canteen] { canteenList } func menu(canteenID: Int, on date: Date) async throws -> CanteenMenu { requests += 1 @@ -118,6 +110,22 @@ struct MensaViewModelTests { #expect(viewModel.state == .unavailable(MensaError.offline.localizedDescription)) } + @Test func replacesACanteenThatIsNoLongerOffered() async throws { + service.canteenList = [ + Canteen(id: 2, name: "Mensa Uhlhornsweg", city: "Oldenburg", address: ""), + Canteen(id: 3, name: "Mensa Wechloy", city: "Oldenburg", address: ""), + ] + settings.canteenID = 1084 // chosen from OpenMensa before + let viewModel = try makeViewModel() + + await viewModel.refresh() + #expect(settings.canteenID == 2) + + settings.canteenID = 3 + await viewModel.refresh() + #expect(settings.canteenID == 3) + } + @Test func formatsStudentPrices() { // The currency style separates the amount with a no-break space. #expect(MensaViewModel.priceText(2.5)?.replacingOccurrences(of: "\u{00A0}", with: " ") == "2,50 €") From ffd506ebaa06cee1fff53cb023a0e785321c3d63 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 15:00:05 +0000 Subject: [PATCH 3/3] docs(mensa): describe the Studierendenwerk Oldenburg source Closes #67 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- docs/FEATURES.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 902d000..9c7b738 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -102,9 +102,11 @@ Code: `App/Intents/`, `Domain/UseCases/QuickInfoUseCases.swift` ## Mensa -Settings → Allgemein → Mensa picks a canteen near the University of Oldenburg (list from OpenMensa, nearest first). „Heute“ then shows its menu grouped by category with the student price; after 15:00 it shows tomorrow's menu. Closed days and missing menus are shown as such; the menu of a day is cached, and the app works without it offline. +Settings → Allgemein → Mensa picks one of the university's canteens: **Mensa Uhlhornsweg** (Campus Haarentor) or **Mensa Wechloy**. „Heute“ then shows its menu grouped by counter with the student price and hints such as „vegan“; after 15:00 it shows tomorrow's menu. Closed days and missing menus are shown as such; the menu of a day is cached, and the app works without it offline. -Code: `Domain/UseCases/MensaUseCases.swift`, `Data/Mensa/` (OpenMensa API v2), `Presentation/Mensa/` +The menu comes from the Studierendenwerk Oldenburg (studierendenwerk-oldenburg.de/essen/speiseplaene-mensen). Its pages embed a menu system that returns a day as HTML (`POST inc/ajax-php_konnektor.inc.php`, `func=make_spl`, location 2 = Uhlhornsweg, 3 = Wechloy); `StudierendenwerkMenuParser` reads counters, meals, prices and icons from it. A canteen chosen from OpenMensa in an earlier version is replaced by Mensa Uhlhornsweg. + +Code: `Domain/UseCases/MensaUseCases.swift`, `Data/Mensa/` (Studierendenwerk Oldenburg), `Presentation/Mensa/` ## Spotlight