Skip to content

Commit 1765f6a

Browse files
Naoki Takahashiclaude
andcommitted
chore: PR 向けの仕上げ (テスト・スリム化・ドキュメント)
- emoji.json を使用フィールド (unified/short_names/sort_order) のみに 絞り込み、サイズを 1.3MB → 136KB (89.6%削減) - EmojiDictionary.search と InputState の絵文字関連遷移に対する ユニットテストを追加 (計24件) - InputState.event() の emoji 関連デフォルト引数に、InputController 以外の経路から誤発動しないための設計意図をコメントで追記 - README に絵文字入力機能の説明と、iamcal/emoji-data (MIT License) のサードパーティデータ表記を追加 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b3dc29a commit 1765f6a

5 files changed

Lines changed: 281 additions & 1 deletion

File tree

Core/Sources/Core/InputUtils/InputState.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ public enum InputState: Sendable, Hashable {
2222
liveConversionEnabled: Bool,
2323
enableDebugWindow: Bool,
2424
enableSuggestion: Bool,
25+
// 絵文字モード関連のデフォルトは「無効」。
26+
// 呼び出し元 (InputController) が Config から値を読んで明示的に渡す想定で、
27+
// InputController 以外の経路 (言語切替時の内部 event 呼び出し等) で誤発動しないようにするため。
28+
// Config.EmojiInputEnabled().default は `true` だが、それは UI 経由のデフォルト値なので別問題。
2529
emojiInputEnabled: Bool = false,
2630
emojiInputTrigger: String = ""
2731
) -> (ClientAction, ClientActionCallback) {

Core/Sources/Core/Resources/emoji.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import Core
2+
import Testing
3+
4+
@Test func testEmojiDictionarySearchReturnsEmptyForEmptyQuery() async throws {
5+
#expect(EmojiDictionary.search(query: "").isEmpty)
6+
}
7+
8+
@Test func testEmojiDictionarySearchFindsExactShortname() async throws {
9+
let results = EmojiDictionary.search(query: "smile")
10+
#expect(!results.isEmpty)
11+
#expect(results.contains { $0.shortnames.contains("smile") })
12+
}
13+
14+
@Test func testEmojiDictionarySearchIsCaseInsensitive() async throws {
15+
let lower = EmojiDictionary.search(query: "smile")
16+
let upper = EmojiDictionary.search(query: "SMILE")
17+
#expect(!lower.isEmpty)
18+
#expect(!upper.isEmpty)
19+
#expect(lower.map(\.emoji) == upper.map(\.emoji))
20+
}
21+
22+
@Test func testEmojiDictionarySearchPrefixMatchesPreferredOverSubstring() async throws {
23+
// "hand" で始まる shortname ("handshake" など) が、substring マッチよりも先に並ぶ
24+
let results = EmojiDictionary.search(query: "hand", limit: 30)
25+
#expect(results.count > 1)
26+
// 先頭数件は "hand" で始まるものがある
27+
let firstFew = results.prefix(3)
28+
#expect(firstFew.contains { entry in
29+
entry.shortnames.contains { $0.lowercased().hasPrefix("hand") }
30+
})
31+
}
32+
33+
@Test func testEmojiDictionarySearchRespectsLimit() async throws {
34+
let results = EmojiDictionary.search(query: "e", limit: 5)
35+
#expect(results.count <= 5)
36+
}
37+
38+
@Test func testEmojiDictionarySearchReturnsValidEmoji() async throws {
39+
let results = EmojiDictionary.search(query: "thumbsup")
40+
let entry = try #require(results.first { $0.shortnames.contains("+1") || $0.shortnames.contains("thumbsup") })
41+
// 絵文字が空でなく、Unicodeスカラが1つ以上含まれる
42+
#expect(!entry.emoji.isEmpty)
43+
#expect(entry.emoji.unicodeScalars.count >= 1)
44+
}
45+
46+
@Test func testEmojiDictionaryEntriesLoaded() async throws {
47+
// iamcal データが正しくロードされていれば1000件以上あるはず
48+
#expect(EmojiDictionary.entries.count > 1000)
49+
}
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import Core
2+
import KanaKanjiConverterModule
3+
import Testing
4+
5+
/// 絵文字入力モード (`.emojiInput`, `.emojiInputNested`) の遷移テスト
6+
private let zeroEvent = KeyEventCore(
7+
modifierFlags: [],
8+
characters: nil,
9+
charactersIgnoringModifiers: nil,
10+
keyCode: 0
11+
)
12+
13+
/// 日本語モードで全角コロン `:` を打ったのと同等の UserAction を作る
14+
private func colonInputAction() -> UserAction {
15+
.input([.key(intention: "", input: ":", modifiers: [])])
16+
}
17+
18+
/// 任意の半角文字を日本語モードで打ったのと同等の UserAction を作る
19+
private func asciiInputAction(_ c: Character) -> UserAction {
20+
guard let fullwidth = Core.KeyMap.h2zMap(c) else {
21+
return .input([.character(c)])
22+
}
23+
return .input([.key(intention: fullwidth, input: c, modifiers: [])])
24+
}
25+
26+
private func runEvent(
27+
state: InputState,
28+
userAction: UserAction,
29+
inputLanguage: InputLanguage = .japanese,
30+
emojiInputEnabled: Bool = true,
31+
emojiInputTrigger: String = ""
32+
) -> (ClientAction, ClientActionCallback) {
33+
state.event(
34+
eventCore: zeroEvent,
35+
userAction: userAction,
36+
inputLanguage: inputLanguage,
37+
liveConversionEnabled: false,
38+
enableDebugWindow: false,
39+
enableSuggestion: false,
40+
emojiInputEnabled: emojiInputEnabled,
41+
emojiInputTrigger: emojiInputTrigger
42+
)
43+
}
44+
45+
// MARK: - トリガー発動
46+
47+
@Test func testColonFromNoneEntersEmojiInput() async throws {
48+
let (action, callback) = runEvent(state: .none, userAction: colonInputAction())
49+
guard case .enterEmojiInputMode = action else {
50+
Issue.record("expected .enterEmojiInputMode, got \(action)")
51+
return
52+
}
53+
guard case .transition(.emojiInput("")) = callback else {
54+
Issue.record("expected .transition(.emojiInput(empty)), got \(callback)")
55+
return
56+
}
57+
}
58+
59+
@Test func testColonFromComposingEntersEmojiInputNested() async throws {
60+
let (action, callback) = runEvent(state: .composing, userAction: colonInputAction())
61+
guard case .enterEmojiInputMode = action else {
62+
Issue.record("expected .enterEmojiInputMode, got \(action)")
63+
return
64+
}
65+
guard case .transition(.emojiInputNested("")) = callback else {
66+
Issue.record("expected .transition(.emojiInputNested(empty)), got \(callback)")
67+
return
68+
}
69+
}
70+
71+
@Test func testColonFromPreviewingEntersEmojiInputNested() async throws {
72+
let (_, callback) = runEvent(state: .previewing, userAction: colonInputAction())
73+
guard case .transition(.emojiInputNested("")) = callback else {
74+
Issue.record("expected .transition(.emojiInputNested(empty)), got \(callback)")
75+
return
76+
}
77+
}
78+
79+
@Test func testColonFromSelectingEntersEmojiInputNested() async throws {
80+
let (_, callback) = runEvent(state: .selecting, userAction: colonInputAction())
81+
guard case .transition(.emojiInputNested("")) = callback else {
82+
Issue.record("expected .transition(.emojiInputNested(empty)), got \(callback)")
83+
return
84+
}
85+
}
86+
87+
// MARK: - 無効化時は発動しない
88+
89+
@Test func testColonDoesNotTriggerWhenDisabled() async throws {
90+
let (action, _) = runEvent(state: .none, userAction: colonInputAction(), emojiInputEnabled: false)
91+
// enterEmojiInputMode が返ってこないことを確認
92+
if case .enterEmojiInputMode = action {
93+
Issue.record("emoji input should not trigger when disabled")
94+
}
95+
}
96+
97+
@Test func testEnglishModeColonDoesNotTriggerEmoji() async throws {
98+
// 英語モードでは :key(intention: nil) になり preferIntention:true でも ":" 一致しない
99+
let englishColon: UserAction = .input([.character(":")])
100+
let (action, _) = runEvent(state: .none, userAction: englishColon, inputLanguage: .english)
101+
if case .enterEmojiInputMode = action {
102+
Issue.record("emoji input should not trigger in English mode")
103+
}
104+
}
105+
106+
// MARK: - emojiInput 中のキー操作
107+
108+
@Test func testLetterAppendsToEmojiQuery() async throws {
109+
let (action, callback) = runEvent(state: .emojiInput("sm"), userAction: asciiInputAction("i"))
110+
guard case .appendToEmojiInput(let appended) = action else {
111+
Issue.record("expected .appendToEmojiInput, got \(action)")
112+
return
113+
}
114+
#expect(appended == "i")
115+
guard case .transition(.emojiInput("smi")) = callback else {
116+
Issue.record("expected .transition(.emojiInput(smi)), got \(callback)")
117+
return
118+
}
119+
}
120+
121+
@Test func testBackspaceShrinksEmojiQuery() async throws {
122+
let (action, callback) = runEvent(state: .emojiInput("smile"), userAction: .backspace)
123+
if case .removeLastEmojiInput = action {} else {
124+
Issue.record("expected .removeLastEmojiInput, got \(action)")
125+
}
126+
guard case .transition(.emojiInput("smil")) = callback else {
127+
Issue.record("expected .transition(.emojiInput(smil)), got \(callback)")
128+
return
129+
}
130+
}
131+
132+
@Test func testBackspaceOnEmptyQueryCancels() async throws {
133+
let (action, callback) = runEvent(state: .emojiInput(""), userAction: .backspace)
134+
if case .cancelEmojiInput = action {} else {
135+
Issue.record("expected .cancelEmojiInput, got \(action)")
136+
}
137+
guard case .transition(.none) = callback else {
138+
Issue.record("expected .transition(.none), got \(callback)")
139+
return
140+
}
141+
}
142+
143+
@Test func testBackspaceOnEmptyNestedQueryReturnsToComposing() async throws {
144+
let (_, callback) = runEvent(state: .emojiInputNested(""), userAction: .backspace)
145+
guard case .transition(.composing) = callback else {
146+
Issue.record("expected .transition(.composing), got \(callback)")
147+
return
148+
}
149+
}
150+
151+
@Test func testEnterSubmitsEmojiCandidate() async throws {
152+
let (action, callback) = runEvent(state: .emojiInput("smile"), userAction: .enter)
153+
if case .submitSelectedEmojiCandidate = action {} else {
154+
Issue.record("expected .submitSelectedEmojiCandidate, got \(action)")
155+
}
156+
guard case .transition(.none) = callback else {
157+
Issue.record("expected .transition(.none), got \(callback)")
158+
return
159+
}
160+
}
161+
162+
@Test func testNestedEnterReturnsToComposing() async throws {
163+
let (_, callback) = runEvent(state: .emojiInputNested("smile"), userAction: .enter)
164+
guard case .transition(.composing) = callback else {
165+
Issue.record("expected .transition(.composing), got \(callback)")
166+
return
167+
}
168+
}
169+
170+
@Test func testEscapeCancels() async throws {
171+
let (action, callback) = runEvent(state: .emojiInput("smile"), userAction: .escape)
172+
if case .cancelEmojiInput = action {} else {
173+
Issue.record("expected .cancelEmojiInput, got \(action)")
174+
}
175+
guard case .transition(.none) = callback else {
176+
Issue.record("expected .transition(.none), got \(callback)")
177+
return
178+
}
179+
}
180+
181+
@Test func testNavigationDownSelectsNext() async throws {
182+
let (action, _) = runEvent(state: .emojiInput("smile"), userAction: .navigation(.down))
183+
if case .selectNextEmojiCandidate = action {} else {
184+
Issue.record("expected .selectNextEmojiCandidate, got \(action)")
185+
}
186+
}
187+
188+
@Test func testNavigationUpSelectsPrev() async throws {
189+
let (action, _) = runEvent(state: .emojiInput("smile"), userAction: .navigation(.up))
190+
if case .selectPrevEmojiCandidate = action {} else {
191+
Issue.record("expected .selectPrevEmojiCandidate, got \(action)")
192+
}
193+
}
194+
195+
@Test func testSecondTriggerCharSubmitsSelected() async throws {
196+
// "smile" のクエリ中にもう一度「:」を打つと選択中候補を確定
197+
let (action, callback) = runEvent(state: .emojiInput("smile"), userAction: colonInputAction())
198+
if case .submitSelectedEmojiCandidate = action {} else {
199+
Issue.record("expected .submitSelectedEmojiCandidate, got \(action)")
200+
}
201+
guard case .transition(.none) = callback else {
202+
Issue.record("expected .transition(.none), got \(callback)")
203+
return
204+
}
205+
}
206+
207+
// MARK: - カスタムトリガー文字
208+
209+
@Test func testCustomTriggerFiresOnly() async throws {
210+
// トリガーを ";" (全角セミコロン) に変更したら、コロンでは発動しない
211+
let customTrigger = ""
212+
let (action, _) = runEvent(
213+
state: .none,
214+
userAction: colonInputAction(),
215+
emojiInputTrigger: customTrigger
216+
)
217+
if case .enterEmojiInputMode = action {
218+
Issue.record("colon should not trigger when custom trigger is set to semicolon")
219+
}
220+
}

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ GitHub Sponsorsをご利用ください。
5151
* LLMによる「いい感じ変換」機能
5252
* ライブ変換
5353
* AZIKのネイティブサポート
54+
* Slack風ショートネーム絵文字入力(日本語入力中に `` を打ち、`:smile` のように英字でインクリメンタル検索して絵文字を確定)
55+
* ON/OFFと起動トリガー文字は「設定 > カスタマイズ > 絵文字入力」で変更可能
56+
* 変換途中でトリガーを打っても、composingテキストを保持したまま絵文字を挿入できる
5457

5558

5659
## 開発ガイド
@@ -149,5 +152,9 @@ Thanks to authors!!
149152
* https://stackoverflow.com/questions/27813151/how-to-develop-a-simple-input-method-for-mac-os-x-in-swift
150153
* https://mzp.booth.pm/items/809262
151154

155+
## Third-Party Data
156+
157+
* 絵文字入力機能のショートネーム/絵文字対応表として、[iamcal/emoji-data](https://github.com/iamcal/emoji-data)(MIT License)の `emoji.json` をバンドルしています。
158+
152159
## Acknowledgement
153160
本プロジェクトは情報処理推進機構(IPA)による[2024年度未踏IT人材発掘・育成事業](https://www.ipa.go.jp/jinzai/mitou/it/2024/koubokekka.html)の支援を受けて開発を行いました。

0 commit comments

Comments
 (0)