copy_tuner_client 1.2.1 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 70c29e81f0f1972e5b8833b0ab7a9f9b079186f055e7d988b207043930144708
4
- data.tar.gz: 3277c0fc92c99421c183f19356ea49f8830df54c8bfdf2913c1433a4e30e6268
3
+ metadata.gz: f671e8161b117c8d8d46b5ae56810ccd0e2fc88dbe9d35ab2175d303c89df11c
4
+ data.tar.gz: 5cf906cd134cda913900d2e15e970a094b23f01a03f9914c746b0e2da3ee6c26
5
5
  SHA512:
6
- metadata.gz: f7ca85e2ebd4d5f8634edc0a2b6c4ac1450d35c49e4bd81740c0bf31166ee8937c1cf29a4af4bda9ba5c1bf30d1985282635c9092a8ee2c6b931a0fcdb78c9f2
7
- data.tar.gz: 680d69dcfee71fb565c08a948a41fae83946fd407045e50e4169266fdb83ef366dbb0c4a16326206ef1e7dae646e3d7a375bf98c88e3d5d8217bb1d755acbff4
6
+ metadata.gz: 84f11e2d259e496f0638b3b8914cc3f88ceda67c6ae1e497f37192fd55347b0f8948abe115c5b91d33ee8feb3acdfb7994fd0af0b4a3118a64ee763dfc458b89
7
+ data.tar.gz: 97f296346a8eff7bb3b09ae4b6de6757973ec859e771492962031440626df13548dc25705ebcb355a14e94038444d1223b325c49caff3a1d87f3ec034bde1561
@@ -8,4 +8,7 @@
8
8
  "cSpell.words": [
9
9
  "mkpath"
10
10
  ],
11
+ "[ruby]": {
12
+ "editor.defaultFormatter": "rubocop.vscode-rubocop",
13
+ }
11
14
  }
data/CLAUDE.md CHANGED
@@ -18,6 +18,11 @@ CopyTuner の Ruby クライアント gem。Rails アプリの I18n を CopyTune
18
18
  - Rack middleware `RequestSync` / `CopyrayMiddleware` — 開発環境でのリクエスト毎同期とオーバーレイ
19
19
  Rails 統合は engine.rb のイニシャライザ経由(ヘルパー/SimpleForm フック、アセット precompile)。
20
20
 
21
+ ## 開発スタイル
22
+ - **RED/TDD で進める**: 実装前に必ず失敗するテストを書き、テストが RED になることを確認してから実装する
23
+ - 新機能・バグ修正ともに「テスト追加 → RED 確認 → 実装 → GREEN」のサイクルを守る
24
+ - テストを書かずに実装を先行させない
25
+
21
26
  ## Gotchas
22
27
  - **フロントエンドは `src/*.ts` を編集する。`app/assets/*` は Vite のビルド成果物なので直接編集しない**
23
28
  (vite.config.ts が `src/main.ts` → `app/assets/javascripts/copytuner.js` を出力)。
@@ -3,7 +3,9 @@ module CopyTunerClient
3
3
  def to_h(dotted_hash)
4
4
  hash = {}
5
5
  dotted_hash.to_h.transform_keys(&:to_s).sort.each do |key, value|
6
- _hash = key.split('.').reverse.inject(value) { |memo, key| { key => memo } }
6
+ # Rails i18n標準との互換性のため、特定のキーを適切な型に変換
7
+ converted_value = convert_value_type(key, value)
8
+ _hash = key.split('.').reverse.inject(converted_value) { |memo, _key| { _key => memo } }
7
9
  hash.deep_merge!(_hash)
8
10
  end
9
11
  hash
@@ -16,8 +18,8 @@ module CopyTunerClient
16
18
  all_keys.each_with_index do |key, index|
17
19
  prefix = "#{key}."
18
20
  conflict_keys = ((index + 1)..Float::INFINITY)
19
- .take_while { |i| all_keys[i]&.start_with?(prefix) }
20
- .map { |i| all_keys[i] }
21
+ .take_while { |i| all_keys[i]&.start_with?(prefix) }
22
+ .map { |i| all_keys[i] }
21
23
 
22
24
  if conflict_keys.present?
23
25
  results[key] = conflict_keys
@@ -27,6 +29,22 @@ module CopyTunerClient
27
29
  results
28
30
  end
29
31
 
30
- module_function :to_h, :conflict_keys
32
+ private
33
+
34
+ def convert_value_type(key, value)
35
+ return value unless value.is_a?(String)
36
+
37
+ # Rails i18n標準で数値型として扱われるキー
38
+ if key.end_with?('.precision')
39
+ value.to_i
40
+ # Rails i18n標準で真偽値として扱われるキー
41
+ elsif key.end_with?('.significant', '.strip_insignificant_zeros')
42
+ value == 'true'
43
+ else
44
+ value
45
+ end
46
+ end
47
+
48
+ module_function :to_h, :conflict_keys, :convert_value_type # rubocop:disable Style/AccessModifierDeclarations
31
49
  end
32
50
  end
@@ -21,12 +21,13 @@ module CopyTunerClient
21
21
  @cache = cache
22
22
  @tree_cache = nil
23
23
  @cache_version = nil
24
- end # Translates the given local and key. See the I18n API documentation for details.
24
+ end
25
+
25
26
  #
26
27
  # @return [Object] the translated key (usually a String)
27
28
  def translate(locale, key, options = {})
28
29
  # I18nの標準処理に任せる(内部でlookupが呼ばれる)
29
- content = super(locale, key, options)
30
+ content = super
30
31
 
31
32
  return content if content.nil? || content.is_a?(Hash)
32
33
 
@@ -43,8 +44,9 @@ module CopyTunerClient
43
44
  # @return [Array<String>] available locales
44
45
  def available_locales
45
46
  return @available_locales if defined?(@available_locales)
47
+
46
48
  cached_locales = cache.keys.map { |key| key.split('.').first }
47
- @available_locales = (cached_locales + super).uniq.map { |locale| locale.to_sym }
49
+ @available_locales = (cached_locales + super).uniq.map(&:to_sym)
48
50
  end
49
51
 
50
52
  # Stores the given translations.
@@ -62,24 +64,26 @@ module CopyTunerClient
62
64
 
63
65
  private
64
66
 
65
- def lookup(locale, key, scope = [], options = {})
67
+ def lookup(locale, key, scope = [], options = {}) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength
66
68
  return nil if !key.is_a?(String) && !key.is_a?(Symbol)
67
69
 
68
70
  parts = I18n.normalize_keys(locale, key, scope, options[:separator])
69
71
  key_with_locale = parts.join('.')
70
72
  key_without_locale = parts[1..].join('.')
71
73
 
72
- if CopyTunerClient::configuration.ignored_keys.include?(key_without_locale)
73
- CopyTunerClient::configuration.ignored_key_handler.call(IgnoredKey.new("Ignored key: #{key_without_locale}"))
74
- end
75
-
76
74
  # NOTE: local_first_key_regexp にマッチするキーは copy_tuner キャッシュをスキップし、
77
75
  # ローカル config/locales(I18n::Backend::Simple)を優先する。段階的にローカルへ移行するための仕組み。
78
76
  # ローカルに無い場合は nil(未訳)のまま返し、copy_tuner へのフォールバックも空キー登録も行わない(完全分離)。
77
+ # ignored_keys より先に評価することで、両方にマッチするキーでも確実にローカルへ委譲する。
79
78
  if local_first_key?(key_without_locale)
80
79
  return super
81
80
  end
82
81
 
82
+ config = CopyTunerClient.configuration
83
+ if config.ignored_keys.include?(key_without_locale)
84
+ config.ignored_key_handler.call(IgnoredKey.new("Ignored key: #{key_without_locale}"))
85
+ end
86
+
83
87
  # NOTE: ハッシュ化した場合に削除されるキーに対応するため、最初に完全一致をチェック(旧クライアントの動作を維持)
84
88
  # 例: `en.test.key` が `en.test.key.conflict` のように別のキーで上書きされている場合の対応
85
89
  exact_match = cache[key_with_locale]
@@ -87,10 +91,9 @@ module CopyTunerClient
87
91
  return exact_match
88
92
  end
89
93
 
90
- # NOTE: 色々考慮する必要があることが分かったため暫定対応として、ツリーキャッシュを使用しないようにしている
91
- # ensure_tree_cache_current
92
- # tree_result = lookup_in_tree_cache(parts)
93
- # return tree_result if tree_result
94
+ ensure_tree_cache_current
95
+ tree_result = lookup_in_tree_cache(parts)
96
+ return tree_result if tree_result
94
97
 
95
98
  content = super
96
99
 
@@ -114,7 +117,9 @@ module CopyTunerClient
114
117
  def lookup_in_tree_cache(keys)
115
118
  return nil if @tree_cache.nil?
116
119
 
117
- symbol_keys = keys.map(&:to_sym)
120
+ # NOTE: keys は I18n.normalize_keys 済みの配列で、数字だけのセグメントは Integer になっている
121
+ # (例: "...body_temperature.36.5" は [..., 36, 5] に分割される)。Integer#to_sym は無いため to_s を経由する。
122
+ symbol_keys = keys.map { |k| k.to_s.to_sym }
118
123
  begin
119
124
  result = @tree_cache.dig(*symbol_keys)
120
125
  result.is_a?(Hash) ? result : nil
@@ -146,21 +151,22 @@ module CopyTunerClient
146
151
  end
147
152
 
148
153
  def default(locale, object, subject, options = {})
149
- content = super(locale, object, subject, options)
154
+ content = super
150
155
  return content if !object.is_a?(String) && !object.is_a?(Symbol)
151
156
 
152
157
  if content.respond_to?(:to_str)
153
158
  parts = I18n.normalize_keys(locale, object, options[:scope], options[:separator])
154
159
  # NOTE: ActionView::Helpers::TranslationHelper#translate wraps default String in an Array
155
160
  # NOTE: local_first キーのアップロード抑止は Cache#[]= 側に集約している
156
- if subject.is_a?(String) || (subject.is_a?(Array) && subject.size == 1 && subject.first.is_a?(String))
157
- key = parts.join('.')
158
- cache[key] = content.to_str
159
- end
161
+ cache[parts.join('.')] = content.to_str if default_string_subject?(subject)
160
162
  end
161
163
  content
162
164
  end
163
165
 
166
+ def default_string_subject?(subject)
167
+ subject.is_a?(String) || (subject.is_a?(Array) && subject.size == 1 && subject.first.is_a?(String))
168
+ end
169
+
164
170
  attr_reader :cache
165
171
  end
166
172
  end
@@ -1,6 +1,6 @@
1
1
  module CopyTunerClient
2
2
  # Client version
3
- VERSION = '1.2.1'.freeze
3
+ VERSION = '1.2.3'.freeze
4
4
 
5
5
  # API version being used to communicate with the server
6
6
  API_VERSION = '2.0'.freeze
@@ -4,25 +4,25 @@ describe CopyTunerClient::DottedHash do
4
4
  describe ".to_h" do
5
5
  subject { CopyTunerClient::DottedHash.to_h(dotted_hash) }
6
6
 
7
- context 'empty keys' do
7
+ context '空のキーの場合' do
8
8
  let(:dotted_hash) { {} }
9
9
 
10
10
  it { is_expected.to eq({}) }
11
11
  end
12
12
 
13
- context 'with single-level keys' do
13
+ context '1階層のキーの場合' do
14
14
  let(:dotted_hash) { { 'key' => 'test value', other_key: 'other value' } }
15
15
 
16
16
  it { is_expected.to eq({ 'key' => 'test value', 'other_key' => 'other value' }) }
17
17
  end
18
18
 
19
- context 'array of key value pairs' do
19
+ context 'キーと値の配列の場合' do
20
20
  let(:dotted_hash) { [['key', 'test value'], ['other_key', 'other value']] }
21
21
 
22
22
  it { is_expected.to eq({ 'key' => 'test value', 'other_key' => 'other value' }) }
23
23
  end
24
24
 
25
- context "with multi-level blurb keys" do
25
+ context "複数階層のblurbキーの場合" do
26
26
  let(:dotted_hash) do
27
27
  {
28
28
  'en.test.key' => 'en test value',
@@ -31,7 +31,7 @@ describe CopyTunerClient::DottedHash do
31
31
  }
32
32
  end
33
33
 
34
- it do
34
+ it "正しくネストされたハッシュに変換されること" do
35
35
  is_expected.to eq({
36
36
  'en' => {
37
37
  'test' => {
@@ -48,7 +48,7 @@ describe CopyTunerClient::DottedHash do
48
48
  end
49
49
  end
50
50
 
51
- context "with conflicting keys" do
51
+ context "キーの競合がある場合" do
52
52
  let(:dotted_hash) do
53
53
  {
54
54
  'en.test' => 'invalid value',
@@ -58,12 +58,86 @@ describe CopyTunerClient::DottedHash do
58
58
 
59
59
  it { is_expected.to eq({ 'en' => { 'test' => { 'key' => 'en test value' } } }) }
60
60
  end
61
+
62
+ context "Rails i18nの数値precisionキーの場合" do
63
+ let(:dotted_hash) do
64
+ {
65
+ 'en.number.currency.format.precision' => '2',
66
+ 'en.number.format.precision' => '3',
67
+ }
68
+ end
69
+
70
+ it "precision値を整数に変換する" do
71
+ is_expected.to eq({
72
+ 'en' => {
73
+ 'number' => {
74
+ 'currency' => {
75
+ 'format' => {
76
+ 'precision' => 2,
77
+ },
78
+ },
79
+ 'format' => {
80
+ 'precision' => 3,
81
+ },
82
+ },
83
+ },
84
+ })
85
+ end
86
+ end
87
+
88
+ context "Rails i18nのbooleanキーの場合" do
89
+ let(:dotted_hash) do
90
+ {
91
+ 'en.number.currency.format.significant' => 'false',
92
+ 'en.number.format.strip_insignificant_zeros' => 'true',
93
+ }
94
+ end
95
+
96
+ it "boolean値を実際の真偽値に変換する" do
97
+ is_expected.to eq({
98
+ 'en' => {
99
+ 'number' => {
100
+ 'currency' => {
101
+ 'format' => {
102
+ 'significant' => false,
103
+ },
104
+ },
105
+ 'format' => {
106
+ 'strip_insignificant_zeros' => true,
107
+ },
108
+ },
109
+ },
110
+ })
111
+ end
112
+ end
113
+
114
+ context "Rails i18n以外で似たパターンを含むキーの場合" do
115
+ let(:dotted_hash) do
116
+ {
117
+ 'en.custom.precision' => 'custom_value',
118
+ 'en.other.significant_value' => 'true',
119
+ }
120
+ end
121
+
122
+ it "Rails i18nパターンで終わるキーのみ変換する" do
123
+ is_expected.to eq({
124
+ 'en' => {
125
+ 'custom' => {
126
+ 'precision' => 0, # .precision suffix triggers conversion
127
+ },
128
+ 'other' => {
129
+ 'significant_value' => 'true', # no conversion for non-exact match
130
+ },
131
+ },
132
+ })
133
+ end
134
+ end
61
135
  end
62
136
 
63
137
  describe ".conflict_keys" do
64
138
  subject { CopyTunerClient::DottedHash.conflict_keys(dotted_hash) }
65
139
 
66
- context 'valid keys' do
140
+ context '有効なキーの場合' do
67
141
  let(:dotted_hash) do
68
142
  {
69
143
  'ja.hoge.test' => 'test',
@@ -74,7 +148,7 @@ describe CopyTunerClient::DottedHash do
74
148
  it { is_expected.to eq({}) }
75
149
  end
76
150
 
77
- context 'invalid keys' do
151
+ context '無効なキーの場合' do
78
152
  let(:dotted_hash) do
79
153
  {
80
154
  'ja.hoge.test' => 'test',
@@ -85,7 +159,7 @@ describe CopyTunerClient::DottedHash do
85
159
  }
86
160
  end
87
161
 
88
- it do
162
+ it "競合するキーが正しく検出されること" do
89
163
  is_expected.to eq({
90
164
  'ja.fuga.test' => %w[ja.fuga.test.hoge],
91
165
  'ja.hoge.test' => %w[ja.hoge.test.fuga ja.hoge.test.hoge],
@@ -244,7 +244,7 @@ describe 'CopyTunerClient::I18nBackend' do
244
244
  end
245
245
 
246
246
  # NOTE: 色々考慮する必要があることが分かったため暫定対応として、ツリーキャッシュを使用しないようにしている
247
- xdescribe 'ツリー構造のlookup' do # rubocop:disable Metrics/BlockLength
247
+ describe 'ツリー構造のlookup' do # rubocop:disable Metrics/BlockLength
248
248
  subject { build_backend }
249
249
 
250
250
  context '完全一致が存在する場合' do
@@ -425,6 +425,23 @@ describe 'CopyTunerClient::I18nBackend' do
425
425
  result = subject.translate('ja', 'hoge.hello', default: nil)
426
426
  expect(result).to be_nil
427
427
  end
428
+
429
+ it '数値セグメントを含むキー(数値enum)でクラッシュしないこと' do
430
+ # NOTE: enumerize の Float range など、normalize_keys が末尾を Integer に分割するキー。
431
+ # exact_match(完全一致)を回避するため別キーを格納し、tree_cache 探索経路を必ず通す。
432
+ cache['ja.dummy'] = 'dummy'
433
+
434
+ expect {
435
+ subject.translate('ja', 'enumerize.infection_control.body_temperature.36.5', default: nil)
436
+ }.not_to raise_error
437
+ end
438
+
439
+ it '数値セグメントキーが存在しても通常キーのlookupが壊れないこと' do
440
+ cache['ja.views.hoge'] = 'normal'
441
+
442
+ expect { subject.translate('ja', 'enumerize.body_temperature.36.5', default: nil) }.not_to raise_error
443
+ expect(subject.translate('ja', 'views.hoge')).to eq('normal')
444
+ end
428
445
  end
429
446
  end
430
447
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: copy_tuner_client
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - SonicGarden