feat(keyboard): add Notes skill and system-style typing
Add save-to-Notes export and direct map handoff while aligning multi-touch typing, period shortcuts, return actions, and navigation visuals with system behavior.
This commit is contained in:
@@ -163,12 +163,24 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
XCTAssertNil(AIAgentShortcutRun.decode(data))
|
||||
}
|
||||
|
||||
func testShortcutsRunURLEncodesNameAndText() {
|
||||
let url = AIAgentShortcutRun.shortcutsRunURL(name: "OSG · 提取待办", text: "买牛奶\n回邮件")
|
||||
func testShortcutsRunURLPreservesNotesFieldSeparator() {
|
||||
let text = "周会纪要\(AINoteExport.fieldSeparator)第一项\n第二项"
|
||||
let url = AIAgentShortcutRun.shortcutsRunURL(name: "OSGSaveToNotes", text: text)
|
||||
let raw = url?.absoluteString ?? ""
|
||||
XCTAssertFalse(raw.contains("<"), "angle brackets in the URL get stripped by Shortcuts")
|
||||
XCTAssertFalse(raw.contains(">"))
|
||||
let items = URLComponents(url: url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||
XCTAssertEqual(items.first { $0.name == "text" }?.value, text)
|
||||
XCTAssertTrue(text.contains("||OSG_NOTE||"))
|
||||
}
|
||||
|
||||
func testShortcutsRunURLIncludesNameAndText() {
|
||||
let shortcutName = AIClipboardSkillCatalog.extractTodosShortcutName
|
||||
let url = AIAgentShortcutRun.shortcutsRunURL(name: shortcutName, text: "买牛奶\n回邮件")
|
||||
XCTAssertEqual(url?.scheme, "shortcuts")
|
||||
XCTAssertEqual(url?.host, "run-shortcut")
|
||||
let items = URLComponents(url: url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||
XCTAssertEqual(items.first { $0.name == "name" }?.value, "OSG · 提取待办")
|
||||
XCTAssertEqual(items.first { $0.name == "name" }?.value, "OSGExtractTodos")
|
||||
XCTAssertEqual(items.first { $0.name == "input" }?.value, "text")
|
||||
XCTAssertEqual(items.first { $0.name == "text" }?.value, "买牛奶\n回邮件")
|
||||
XCTAssertNil(items.first { $0.name == "x-success" })
|
||||
@@ -176,7 +188,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
|
||||
func testXCallbackRunURLUsesCallbackHost() {
|
||||
let url = AIAgentShortcutRun.shortcutsRunURL(
|
||||
name: "OSG · 提取待办",
|
||||
name: AIClipboardSkillCatalog.extractTodosShortcutName,
|
||||
text: "买牛奶",
|
||||
xSuccess: "osgkeyboard://skill/shortcut-result?status=success",
|
||||
xError: "osgkeyboard://skill/shortcut-result?status=error",
|
||||
@@ -197,51 +209,67 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
XCTAssertEqual(AIAgentShortcutRun.preview("买牛奶\n回邮件"), "买牛奶\\n回邮件")
|
||||
}
|
||||
|
||||
func testExtractTodosUsesICloudShareLink() {
|
||||
func testExtractTodosUsesBundledShortcut() {
|
||||
let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.extractTodosID)
|
||||
XCTAssertEqual(
|
||||
skill?.shortcutICloudURL,
|
||||
AIClipboardSkillCatalog.extractTodosShortcutICloudURL
|
||||
)
|
||||
XCTAssertEqual(skill?.shortcutICloudURL?.host, "www.icloud.com")
|
||||
XCTAssertEqual(skill?.shortcutName, "OSG · 提取待办")
|
||||
XCTAssertNil(skill?.shortcutICloudURL)
|
||||
XCTAssertEqual(skill?.shortcutName, "OSGExtractTodos")
|
||||
XCTAssertEqual(skill?.shortcutResourceName, "OSGExtractTodos")
|
||||
}
|
||||
|
||||
func testExtractEventsUsesICloudShareLink() {
|
||||
func testExtractEventsUsesBundledShortcut() {
|
||||
let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.extractEventsID)
|
||||
XCTAssertEqual(
|
||||
skill?.shortcutICloudURL,
|
||||
AIClipboardSkillCatalog.extractEventsShortcutICloudURL
|
||||
)
|
||||
XCTAssertEqual(skill?.shortcutICloudURL?.host, "www.icloud.com")
|
||||
XCTAssertEqual(skill?.shortcutName, "OSG · 提取日程")
|
||||
XCTAssertNil(skill?.shortcutICloudURL)
|
||||
XCTAssertEqual(skill?.shortcutName, "OSGExtractEvents")
|
||||
XCTAssertEqual(
|
||||
skill?.shortcutResourceName,
|
||||
"OSGExtractEvents"
|
||||
)
|
||||
XCTAssertEqual(skill?.systemImage, "calendar")
|
||||
XCTAssertFalse(skill?.isDefault ?? true)
|
||||
XCTAssertEqual(
|
||||
AIAgentShortcutRun.iCloudShareToken(from: skill!.shortcutICloudURL!),
|
||||
"1f4afcf7ee22400cbf84e319d969aadf"
|
||||
)
|
||||
}
|
||||
|
||||
func testNavigateUsesBundledShortcut() {
|
||||
func testNavigateDoesNotRequireShortcut() {
|
||||
let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.navigateID)
|
||||
XCTAssertNil(skill?.shortcutName)
|
||||
XCTAssertNil(skill?.shortcutICloudURL)
|
||||
XCTAssertEqual(skill?.shortcutName, "OSG · 导航")
|
||||
XCTAssertEqual(skill?.shortcutResourceName, "OSGNavigate")
|
||||
XCTAssertNil(skill?.shortcutResourceName)
|
||||
XCTAssertEqual(
|
||||
skill?.systemImage,
|
||||
"arrow.triangle.turn.up.right.diamond.fill"
|
||||
)
|
||||
XCTAssertFalse(skill?.isDefault ?? true)
|
||||
XCTAssertEqual(skill?.kind, .export)
|
||||
XCTAssertFalse(skill?.requiresShortcut ?? true)
|
||||
}
|
||||
|
||||
func testNavigateEnablesWithoutShortcutConfirmation() {
|
||||
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
|
||||
XCTAssertEqual(
|
||||
store.enable(AIClipboardSkillCatalog.navigateID),
|
||||
.enabled
|
||||
)
|
||||
XCTAssertTrue(store.layout.isEnabled(AIClipboardSkillCatalog.navigateID))
|
||||
}
|
||||
|
||||
func testSaveToNotesUsesBundledShortcutUntilICloudShareExists() {
|
||||
let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.saveToNotesID)
|
||||
XCTAssertNil(skill?.shortcutICloudURL)
|
||||
XCTAssertEqual(skill?.shortcutName, "OSGSaveToNotes")
|
||||
XCTAssertEqual(skill?.shortcutResourceName, "OSGSaveToNotes")
|
||||
XCTAssertEqual(skill?.systemImage, "note.text")
|
||||
XCTAssertFalse(skill?.isDefault ?? true)
|
||||
XCTAssertTrue(skill?.requiresShortcut ?? false)
|
||||
}
|
||||
|
||||
func testCannotEnableSaveToNotesBeforeShortcutConfirmation() {
|
||||
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
|
||||
XCTAssertEqual(
|
||||
store.enable(AIClipboardSkillCatalog.saveToNotesID),
|
||||
.needsShortcut
|
||||
)
|
||||
XCTAssertFalse(store.layout.isEnabled(AIClipboardSkillCatalog.saveToNotesID))
|
||||
}
|
||||
|
||||
func testICloudShareLinkMapsToShortcutsInstallURL() {
|
||||
let share = URL(string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44")!
|
||||
let url = AIAgentShortcutRun.shortcutsInstallURL(from: share)
|
||||
|
||||
@@ -265,4 +265,15 @@ final class AIClipboardSkillTests: XCTestCase {
|
||||
XCTAssertTrue(prompt.contains("NONE"))
|
||||
XCTAssertTrue(prompt.contains("起点|终点"))
|
||||
}
|
||||
|
||||
func testSaveToNotesAsksForTitleNotBody() {
|
||||
let prompt = AIClipboardSkillCatalog.instruction(
|
||||
skillID: AIClipboardSkillCatalog.saveToNotesID,
|
||||
locale: "zh",
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
|
||||
)
|
||||
XCTAssertTrue(prompt.contains("标题"))
|
||||
XCTAssertTrue(prompt.contains("不要输出正文"))
|
||||
XCTAssertTrue(prompt.contains("不要输出 NONE"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// AINoteExportTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class AINoteExportTests: XCTestCase {
|
||||
private var calendar: Calendar {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")!
|
||||
return calendar
|
||||
}
|
||||
|
||||
private var now: Date {
|
||||
date(2026, 8, 13, 23, 56)
|
||||
}
|
||||
|
||||
func testEmptyClipboardProducesNoItems() {
|
||||
XCTAssertEqual(items(from: "周会纪要", clipboard: nil), [])
|
||||
XCTAssertEqual(items(from: "周会纪要", clipboard: " \n "), [])
|
||||
}
|
||||
|
||||
func testUsesModelTitleAndKeepsOriginalBody() {
|
||||
XCTAssertEqual(
|
||||
items(from: "8月13日周会纪要", clipboard: "第一项\n第二项"),
|
||||
["8月13日周会纪要\(AINoteExport.fieldSeparator)第一项\n第二项"]
|
||||
)
|
||||
}
|
||||
|
||||
func testIgnoresModelBodyAfterFirstLine() {
|
||||
let answer = """
|
||||
购物清单
|
||||
这是模型改写过的正文,不应写入备忘录。
|
||||
"""
|
||||
XCTAssertEqual(
|
||||
items(from: answer, clipboard: "买牛奶\n买鸡蛋"),
|
||||
["购物清单\(AINoteExport.fieldSeparator)买牛奶\n买鸡蛋"]
|
||||
)
|
||||
}
|
||||
|
||||
func testTakesTitleBeforePipe() {
|
||||
XCTAssertEqual(
|
||||
items(from: "会议纪要|请忽略这段模型正文", clipboard: "原文"),
|
||||
["会议纪要\(AINoteExport.fieldSeparator)原文"]
|
||||
)
|
||||
}
|
||||
|
||||
func testNONEFallsBackToDatedSnippet() {
|
||||
XCTAssertEqual(
|
||||
items(from: "NONE", clipboard: "买牛奶"),
|
||||
["8月13日 · 买牛奶\(AINoteExport.fieldSeparator)买牛奶"]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
items(from: "没有标题", clipboard: "买牛奶"),
|
||||
["8月13日 · 买牛奶\(AINoteExport.fieldSeparator)买牛奶"]
|
||||
)
|
||||
}
|
||||
|
||||
func testEnglishFallbackUsesMonthDay() {
|
||||
XCTAssertEqual(
|
||||
items(from: "NONE", clipboard: "Buy milk", locale: "en"),
|
||||
["13 Aug · Buy milk\(AINoteExport.fieldSeparator)Buy milk"]
|
||||
)
|
||||
}
|
||||
|
||||
func testStripsQuotesAndBullets() {
|
||||
XCTAssertEqual(
|
||||
items(from: "「周会纪要」", clipboard: "纪要正文"),
|
||||
["周会纪要\(AINoteExport.fieldSeparator)纪要正文"]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
items(from: "- 周会纪要", clipboard: "纪要正文"),
|
||||
["周会纪要\(AINoteExport.fieldSeparator)纪要正文"]
|
||||
)
|
||||
}
|
||||
|
||||
func testKeepsTitleAndBodyWhenTheyMatch() {
|
||||
XCTAssertEqual(
|
||||
items(from: "买牛奶", clipboard: "买牛奶"),
|
||||
["买牛奶\(AINoteExport.fieldSeparator)买牛奶"]
|
||||
)
|
||||
}
|
||||
|
||||
func testTruncatesLongTitle() {
|
||||
let title = String(repeating: "纪", count: 50)
|
||||
let items = items(from: title, clipboard: "正文")
|
||||
XCTAssertEqual(items.count, 1)
|
||||
let fields = items[0].components(separatedBy: AINoteExport.fieldSeparator)
|
||||
XCTAssertEqual(fields.count, 2)
|
||||
XCTAssertEqual(fields[0].count, AINoteExport.maximumTitleLength)
|
||||
XCTAssertEqual(fields[1], "正文")
|
||||
}
|
||||
|
||||
func testWholeClipboardEchoFallsBack() {
|
||||
let source = String(repeating: "这是一段很长的会议纪要内容,包含许多句子。", count: 4)
|
||||
let items = items(from: source, clipboard: source)
|
||||
XCTAssertEqual(items.count, 1)
|
||||
XCTAssertTrue(items[0].hasPrefix("8月13日 · "))
|
||||
XCTAssertTrue(items[0].hasSuffix("\(AINoteExport.fieldSeparator)\(source)"))
|
||||
}
|
||||
|
||||
func testPromptAsksForTitleOnly() {
|
||||
let prompt = AIClipboardSkillCatalog.instruction(
|
||||
skillID: AIClipboardSkillCatalog.saveToNotesID,
|
||||
locale: "zh",
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
|
||||
now: now
|
||||
)
|
||||
XCTAssertTrue(prompt.contains("标题"))
|
||||
XCTAssertTrue(prompt.contains("不要输出正文"))
|
||||
XCTAssertTrue(prompt.contains("不要改写"))
|
||||
}
|
||||
|
||||
func testEnglishPromptAsksForTitleOnly() {
|
||||
let prompt = AIClipboardSkillCatalog.instruction(
|
||||
skillID: AIClipboardSkillCatalog.saveToNotesID,
|
||||
locale: "en",
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
|
||||
now: now
|
||||
)
|
||||
XCTAssertTrue(prompt.contains("title"))
|
||||
XCTAssertTrue(prompt.contains("do not output the body"))
|
||||
}
|
||||
|
||||
private func items(
|
||||
from answer: String,
|
||||
clipboard: String?,
|
||||
locale: String = "zh"
|
||||
) -> [String] {
|
||||
AINoteExport.items(
|
||||
from: answer,
|
||||
sourceClipboard: clipboard,
|
||||
now: now,
|
||||
locale: locale,
|
||||
calendar: calendar
|
||||
)
|
||||
}
|
||||
|
||||
private func date(_ year: Int, _ month: Int, _ day: Int, _ hour: Int, _ minute: Int) -> Date {
|
||||
calendar.date(
|
||||
from: DateComponents(
|
||||
year: year,
|
||||
month: month,
|
||||
day: day,
|
||||
hour: hour,
|
||||
minute: minute
|
||||
)
|
||||
)!
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user