feat(AI): 集成MNN推理引擎替换MLX作为主AI运行时 - 引入MNN(alibaba) + Arm SME2 + CPU作为主AI运行时,支持A19/iPhone17的 SME2和A17的NEON加速 - 添加MLX Swift作为兜底GPU推理方案,实现双后端切换机制 - 使用单一Qwen3.5-2B多模态模型(1.2GB),替代原有的LLM+VL分离架构 - 实现InferenceEngine.current引擎选择逻辑,真机默认MNN,模拟器回退MLX - 更新AIAgent架构,通过MNNLLMBridge(ObjC++) → MNNBackend进行推理 - 修改队列机制防止并发推理导致OOM,使用信号量闸门控制显存占用 - 更新文档中的技术栈说明、模块边界和周次交付计划 ```
104 lines
4.2 KiB
Swift
104 lines
4.2 KiB
Swift
import Testing
|
||
import Foundation
|
||
import SwiftData
|
||
@testable import 康康
|
||
|
||
/// MedicationScanService.parseMedicationsJSON 纯函数单测(JSON 容错与去重)。
|
||
struct MedicationScanServiceTests {
|
||
|
||
@Test func parsesStandardObject() throws {
|
||
let raw = """
|
||
{"medications":[{"name":"缬沙坦胶囊","strength":"80mg×7粒","usage":""}]}
|
||
"""
|
||
let meds = try MedicationScanService.parseMedicationsJSON(raw)
|
||
#expect(meds.count == 1)
|
||
#expect(meds[0].name == "缬沙坦胶囊")
|
||
#expect(meds[0].strength == "80mg×7粒")
|
||
#expect(meds[0].entryText == "缬沙坦胶囊 80mg×7粒")
|
||
}
|
||
|
||
@Test func parsesBareArrayWithFence() throws {
|
||
let raw = """
|
||
```json
|
||
[{"name":"二甲双胍缓释片","strength":"0.5g×30片","usage":"口服,一次1片,一日2次"}]
|
||
```
|
||
"""
|
||
let meds = try MedicationScanService.parseMedicationsJSON(raw)
|
||
#expect(meds.count == 1)
|
||
#expect(meds[0].entryText == "二甲双胍缓释片 0.5g×30片 · 口服,一次1片,一日2次")
|
||
}
|
||
|
||
@Test func parsesChineseKeysAndDedupes() throws {
|
||
let raw = """
|
||
{"medications":[
|
||
{"药名":"阿司匹林肠溶片","规格":"100mg","用法":""},
|
||
{"name":"阿司匹林肠溶片","strength":"100mg","usage":""}
|
||
]}
|
||
"""
|
||
let meds = try MedicationScanService.parseMedicationsJSON(raw)
|
||
#expect(meds.count == 1)
|
||
}
|
||
|
||
@Test func emptyNameRowsAreDropped() throws {
|
||
let raw = #"{"medications":[{"name":"","strength":"10mg","usage":""}]}"#
|
||
let meds = try MedicationScanService.parseMedicationsJSON(raw)
|
||
#expect(meds.isEmpty)
|
||
}
|
||
|
||
@Test func trailingCommaIsRepaired() throws {
|
||
let raw = #"{"medications":[{"name":"氯雷他定片","strength":"10mg×6片","usage":"",},]}"#
|
||
let meds = try MedicationScanService.parseMedicationsJSON(raw)
|
||
#expect(meds.count == 1)
|
||
#expect(meds[0].name == "氯雷他定片")
|
||
}
|
||
|
||
@Test func invalidJSONThrows() {
|
||
#expect(throws: (any Error).self) {
|
||
try MedicationScanService.parseMedicationsJSON("识别不出来,抱歉")
|
||
}
|
||
}
|
||
|
||
/// 英文药名照原样保留(prompt 已要求英文不翻译);解析层只透传,不应被丢弃。
|
||
@Test func parsesEnglishDrugName() throws {
|
||
let raw = #"{"medications":[{"name":"Amoxicillin","strength":"500mg","usage":"Take one capsule three times daily"}]}"#
|
||
let meds = try MedicationScanService.parseMedicationsJSON(raw)
|
||
#expect(meds.count == 1)
|
||
#expect(meds[0].name == "Amoxicillin")
|
||
#expect(meds[0].entryText == "Amoxicillin 500mg · Take one capsule three times daily")
|
||
}
|
||
|
||
/// 通用名 + 商品名合并写法(prompt 约定 "通用名(商品名)")原样透传。
|
||
@Test func parsesGenericWithBrandName() throws {
|
||
let raw = #"{"medications":[{"name":"缬沙坦胶囊(代文)","strength":"80mg×7粒","usage":""}]}"#
|
||
let meds = try MedicationScanService.parseMedicationsJSON(raw)
|
||
#expect(meds.count == 1)
|
||
#expect(meds[0].name == "缬沙坦胶囊(代文)")
|
||
}
|
||
}
|
||
|
||
/// 「用药」日记 → 时间线分类映射(拍药盒入档落库后在「记录」tab 的归类)。
|
||
@MainActor
|
||
struct MedicationTimelineTests {
|
||
|
||
private func makeContext() throws -> ModelContext {
|
||
// DiaryEntry 现关联 Asset(拍药盒原图),schema 必须带上 Asset.self,否则建容器报错。
|
||
let schema = Schema([DiaryEntry.self, Asset.self])
|
||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||
return ModelContext(try ModelContainer(for: schema, configurations: [config]))
|
||
}
|
||
|
||
@Test func medicationTaggedDiaryMapsToMedicationKind() throws {
|
||
let ctx = try makeContext()
|
||
let med = DiaryEntry(content: "缬沙坦胶囊 80mg×7粒", tags: [DiaryEntry.medicationTag])
|
||
let plain = DiaryEntry(content: "今天睡得不错")
|
||
ctx.insert(med); ctx.insert(plain)
|
||
try ctx.save()
|
||
|
||
let medEntry = TimelineEntry.from(diary: med)
|
||
#expect(medEntry.kind == .medication)
|
||
#expect(medEntry.title == "缬沙坦胶囊 80mg×7粒")
|
||
|
||
#expect(TimelineEntry.from(diary: plain).kind == .diary)
|
||
}
|
||
}
|