Files
OSGKeyboard/OSGKeyboardExtTests/LibrimeIntegrationTests.swift
T
Rocky 717902716a feat(typing): wire PersonalDictionary into Chinese Pinyin via Rime sidecar
Redeploy an osg_personal import table on dictionary add/delete/sync so
Chinese, English typing, and ASR share one curated lexicon (next keyboard open).
2026-08-05 22:12:30 +08:00

268 lines
11 KiB
Swift

// LibrimeIntegrationTests.swift
// OSGKeyboard · Ext unit tests
import XCTest
@testable import OSGKeyboardShared
final class LibrimeIntegrationTests: XCTestCase {
func testFullPinyinAndBothDoublePinyinSchemasProducePhraseCandidates() throws {
let fileManager = FileManager.default
let root = fileManager.temporaryDirectory
.appendingPathComponent("OSGRimeTests-\(UUID().uuidString)", isDirectory: true)
let shared = root.appendingPathComponent("SharedSupport", isDirectory: true)
let user = root.appendingPathComponent("UserData", isDirectory: true)
defer { try? fileManager.removeItem(at: root) }
try fileManager.createDirectory(at: shared, withIntermediateDirectories: true)
try fileManager.createDirectory(at: user, withIntermediateDirectories: true)
let bundle = Bundle(for: LibrimeIntegrationTests.self)
let dictionary = try XCTUnwrap(
bundle.url(forResource: "osg_pinyin.dict", withExtension: "yaml")
)
try fileManager.copyItem(
at: dictionary,
to: shared.appendingPathComponent("osg_pinyin.dict.yaml")
)
try RimeSchemaGenerator.defaultConfiguration().write(
to: shared.appendingPathComponent("default.yaml"),
atomically: true,
encoding: .utf8
)
for schema in TypingInputSchema.allCases {
try RimeSchemaGenerator.schema(for: schema, fuzzyPairs: []).write(
to: shared.appendingPathComponent("\(schema.rawValue).schema.yaml"),
atomically: true,
encoding: .utf8
)
}
let deployer = OSGRimeBridge(
sharedDataDirectory: shared.path,
userDataDirectory: user.path,
distributionVersion: "tests"
)
try deployer.deploy(withFullCheck: true)
deployer.finalizeRuntime()
let bridge = OSGRimeBridge(
sharedDataDirectory: shared.path,
userDataDirectory: user.path,
distributionVersion: "tests"
)
try bridge.start()
let vectors: [(TypingInputSchema, String)] = [
(.fullPinyin, "nihao"),
(.microsoftDoublePinyin, "nihk"),
(.sogouDoublePinyin, "nihk")
]
for (schema, keys) in vectors {
bridge.clearComposition()
XCTAssertTrue(bridge.selectSchema(schema.rawValue), schema.displayName)
let startedAt = ProcessInfo.processInfo.systemUptime
for scalar in keys.utf8 {
XCTAssertTrue(bridge.processKeyCode(Int32(scalar), modifiers: 0))
}
let snapshot = bridge.snapshot(withCandidateLimit: 100)
XCTAssertLessThan(
ProcessInfo.processInfo.systemUptime - startedAt,
0.35,
"\(schema.displayName) first candidates exceeded 350 ms"
)
XCTAssertFalse(snapshot.preedit.isEmpty, schema.displayName)
XCTAssertTrue(
snapshot.candidates.contains(where: { $0.text == "你好" }),
"\(schema.displayName) candidates: \(snapshot.candidates.map(\.text).prefix(20))"
)
if schema == .fullPinyin,
let hello = snapshot.candidates.first(where: { $0.text == "你好" }) {
XCTAssertTrue(bridge.selectCandidate(at: hello.index))
XCTAssertEqual(
bridge.snapshot(withCandidateLimit: 10).commitText,
"你好"
)
}
}
// Incomplete multi-syllable input should keep phrase completions and
// still expose first-syllable characters (PC-IME progressive style).
bridge.clearComposition()
XCTAssertTrue(bridge.selectSchema(TypingInputSchema.fullPinyin.rawValue))
for scalar in "zhongg".utf8 {
XCTAssertTrue(bridge.processKeyCode(Int32(scalar), modifiers: 0))
}
let zhongg = bridge.snapshot(withCandidateLimit: 160)
XCTAssertTrue(
zhongg.candidates.contains(where: { $0.text == "中国" }),
"zhongg phrases: \(zhongg.candidates.map(\.text).prefix(30))"
)
XCTAssertTrue(
zhongg.candidates.contains(where: { $0.text == "中" }),
"zhongg should keep first-syllable 中: \(zhongg.candidates.map(\.text).prefix(40))"
)
if let china = zhongg.candidates.firstIndex(where: { $0.text == "中国" }),
let zhong = zhongg.candidates.firstIndex(where: { $0.text == "中" }) {
XCTAssertLessThan(china, zhong, "phrases should precede first-syllable chars")
}
XCTAssertGreaterThanOrEqual(zhongg.candidates.count, 40)
bridge.clearComposition()
for scalar in "zhao".utf8 {
XCTAssertTrue(bridge.processKeyCode(Int32(scalar), modifiers: 0))
}
let zhao = bridge.snapshot(withCandidateLimit: 160)
XCTAssertGreaterThanOrEqual(
zhao.candidates.count,
40,
"zhao candidates: \(zhao.candidates.map(\.text).prefix(20))"
)
XCTAssertTrue(bridge.selectSchema(TypingInputSchema.fullPinyin.rawValue))
let phraseVectors = [
("rengongzhineng", "人工智能"),
("yuyinshuru", "语音输入"),
("zhonghuarenmingongheguo", "中华人民共和国")
]
for (keys, expected) in phraseVectors {
bridge.clearComposition()
for scalar in keys.utf8 {
XCTAssertTrue(bridge.processKeyCode(Int32(scalar), modifiers: 0))
}
let snapshot = bridge.snapshot(withCandidateLimit: 100)
XCTAssertTrue(
snapshot.candidates.contains(where: { $0.text == expected }),
"\(keys) candidates: \(snapshot.candidates.map(\.text).prefix(20))"
)
}
bridge.finalizeRuntime()
let userFiles = fileManager.enumerator(atPath: user.path)?
.compactMap { $0 as? String } ?? []
XCTAssertTrue(userFiles.contains(where: { $0.contains("userdb") }))
// Redeploy with one fuzzy pair and verify it affects actual Rime
// candidates rather than just generated YAML.
for schema in TypingInputSchema.allCases {
try RimeSchemaGenerator.schema(for: schema, fuzzyPairs: [.nL]).write(
to: shared.appendingPathComponent("\(schema.rawValue).schema.yaml"),
atomically: true,
encoding: .utf8
)
}
let fuzzyDeployer = OSGRimeBridge(
sharedDataDirectory: shared.path,
userDataDirectory: user.path,
distributionVersion: "tests-fuzzy"
)
try fuzzyDeployer.deploy(withFullCheck: true)
fuzzyDeployer.finalizeRuntime()
let fuzzyBridge = OSGRimeBridge(
sharedDataDirectory: shared.path,
userDataDirectory: user.path,
distributionVersion: "tests-fuzzy"
)
try fuzzyBridge.start()
XCTAssertTrue(fuzzyBridge.selectSchema(TypingInputSchema.fullPinyin.rawValue))
for scalar in "lihao".utf8 {
XCTAssertTrue(fuzzyBridge.processKeyCode(Int32(scalar), modifiers: 0))
}
let fuzzySnapshot = fuzzyBridge.snapshot(withCandidateLimit: 100)
XCTAssertTrue(
fuzzySnapshot.candidates.contains(where: { $0.text == "你好" }),
"n/l fuzzy candidates: \(fuzzySnapshot.candidates.map(\.text).prefix(20))"
)
fuzzyBridge.finalizeRuntime()
}
func testPersonalDictionarySidecarPinsSameCodeCandidates() throws {
let fileManager = FileManager.default
let root = fileManager.temporaryDirectory
.appendingPathComponent("OSGRimePersonal-\(UUID().uuidString)", isDirectory: true)
let shared = root.appendingPathComponent("SharedSupport", isDirectory: true)
let user = root.appendingPathComponent("UserData", isDirectory: true)
defer { try? fileManager.removeItem(at: root) }
try fileManager.createDirectory(at: shared, withIntermediateDirectories: true)
try fileManager.createDirectory(at: user, withIntermediateDirectories: true)
let bundle = Bundle(for: LibrimeIntegrationTests.self)
let dictionaryURL = try XCTUnwrap(
bundle.url(forResource: "osg_pinyin.dict", withExtension: "yaml")
)
let baseline = try String(contentsOf: dictionaryURL, encoding: .utf8)
let patched = RimePersonalDictionaryExporter.injectingImportTables(into: baseline)
try patched.write(
to: shared.appendingPathComponent("osg_pinyin.dict.yaml"),
atomically: true,
encoding: .utf8
)
// Unique personal phrase on a common code — must outrank baseline “你好”.
let personalYAML = RimePersonalDictionaryExporter.yaml(
entries: [
.init(text: "尼好专名", code: "ni hao"),
.init(text: "ChatGPT", code: "chatgpt")
]
)
try personalYAML.write(
to: shared.appendingPathComponent("osg_personal.dict.yaml"),
atomically: true,
encoding: .utf8
)
try RimeSchemaGenerator.defaultConfiguration().write(
to: shared.appendingPathComponent("default.yaml"),
atomically: true,
encoding: .utf8
)
for schema in TypingInputSchema.allCases {
try RimeSchemaGenerator.schema(for: schema, fuzzyPairs: []).write(
to: shared.appendingPathComponent("\(schema.rawValue).schema.yaml"),
atomically: true,
encoding: .utf8
)
}
let deployer = OSGRimeBridge(
sharedDataDirectory: shared.path,
userDataDirectory: user.path,
distributionVersion: "tests-personal"
)
try deployer.deploy(withFullCheck: true)
deployer.finalizeRuntime()
let bridge = OSGRimeBridge(
sharedDataDirectory: shared.path,
userDataDirectory: user.path,
distributionVersion: "tests-personal"
)
try bridge.start()
XCTAssertTrue(bridge.selectSchema(TypingInputSchema.fullPinyin.rawValue))
for scalar in "nihao".utf8 {
XCTAssertTrue(bridge.processKeyCode(Int32(scalar), modifiers: 0))
}
let chinese = bridge.snapshot(withCandidateLimit: 40)
XCTAssertTrue(
chinese.candidates.contains(where: { $0.text == "尼好专名" }),
"personal Chinese missing: \(chinese.candidates.map(\.text).prefix(20))"
)
if let personal = chinese.candidates.firstIndex(where: { $0.text == "尼好专名" }),
let baselineHello = chinese.candidates.firstIndex(where: { $0.text == "你好" }) {
XCTAssertLessThan(personal, baselineHello, "personal same-code should pin above baseline")
}
bridge.clearComposition()
for scalar in "chatgpt".utf8 {
XCTAssertTrue(bridge.processKeyCode(Int32(scalar), modifiers: 0))
}
let english = bridge.snapshot(withCandidateLimit: 40)
XCTAssertTrue(
english.candidates.contains(where: { $0.text == "ChatGPT" }),
"personal English missing: \(english.candidates.map(\.text).prefix(20))"
)
bridge.finalizeRuntime()
}
}