copy_tuner_client 1.0.0 → 1.1.0

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: 20f12b5601b4d8e9820a391c6604f94ac2610ecc459731c55e7bf3f8c45a93f6
4
- data.tar.gz: 732cb4c984f8879f19a3e9a02e5723e4503b15b3e5bbdb1651cd41e968ae1d1e
3
+ metadata.gz: 077fca3432ac4660b4b4211b4546d5fe76c0ad8bb77c2c675a95bcf2e24d7090
4
+ data.tar.gz: 07ca6d48a19d32179a9aecc1b92d4c1550cc489b06c893cc80a25557bfbb3b8e
5
5
  SHA512:
6
- metadata.gz: 6043c039ba1e0769325dd06a37ee497a8801263fcbdba75a572762dd42d0bba098412bab85e1dd5867f2cf49780c5bc8121f15ab40d90044deb5a7ae923ba179
7
- data.tar.gz: 9c63db0d3b56745876c4e7b28127c9ef77843fe5deb6071a23b1c4ad38437910043a2291586e461c02d33ece18c30e76f609f8a851fa8d01683272990933f9e1
6
+ metadata.gz: 5806d46e0b1eae66187806615b387b9254c6c667dcf43c38312cda68f8dc1a7865475ea40e01a5166ef4678b7474652f2395e14986b29ae2cf5babc10be9725d
7
+ data.tar.gz: 6fe660fbe9406975a1aef91b236b3c718201606f003da46ea96b3fc1b437e0e0e50ff1dc86cd7f527c5efcde52dac3a602c0fd15ed7d2e7bdc182669c06eac77
@@ -71,7 +71,21 @@ module CopyTunerClient
71
71
  # Yaml representation of all blurbs
72
72
  # @return [String] yaml
73
73
  def export
74
- lock { @blurbs.present? ? DottedHash.to_h(@blurbs).to_yaml : nil }
74
+ tree_hash = to_tree_hash
75
+ tree_hash.present? ? tree_hash.to_yaml : nil
76
+ end
77
+
78
+ # ツリー構造のハッシュを返す(I18nBackend用)
79
+ # @return [Hash] ツリー構造に変換されたblurbs
80
+ def to_tree_hash
81
+ lock { @blurbs.present? ? DottedHash.to_h(@blurbs) : {} }
82
+ end
83
+
84
+ # キャッシュの更新バージョンを返す(ツリーキャッシュの無効化判定用)
85
+ # ETags を使用してサーバーサイドの更新を検知
86
+ # @return [String, nil] 現在のETag値
87
+ def version
88
+ client.etag
75
89
  end
76
90
 
77
91
  # Waits until the first download has finished.
@@ -18,6 +18,10 @@ module CopyTunerClient
18
18
 
19
19
  USER_AGENT = "copy_tuner_client #{CopyTunerClient::VERSION}"
20
20
 
21
+ # ETags を外部から取得可能にする
22
+ # @return [String, nil] 現在のETag値
23
+ attr_reader :etag
24
+
21
25
  # Usually instantiated from {Configuration#apply}. Copies options.
22
26
  # @param options [Hash]
23
27
  # @option options [String] :api_key API key of the project to connect to
@@ -113,9 +113,6 @@ module CopyTunerClient
113
113
 
114
114
  attr_accessor :poller
115
115
 
116
- # @return [Boolean] To enable inline-translation-mode, set true.
117
- attr_accessor :inline_translation
118
-
119
116
  # @return [Regexp] Regular expression to exclude keys.
120
117
  attr_accessor :exclude_key_regexp
121
118
 
@@ -1,5 +1,6 @@
1
1
  require 'i18n'
2
2
  require 'copy_tuner_client/configuration'
3
+ require 'active_support/core_ext/hash/keys'
3
4
 
4
5
  module CopyTunerClient
5
6
  # I18n implementation designed to synchronize with CopyTuner.
@@ -18,26 +19,27 @@ module CopyTunerClient
18
19
  # @param cache [Cache] must act like a hash, returning and accept blurbs by key.
19
20
  def initialize(cache)
20
21
  @cache = cache
21
- end
22
-
23
- # Translates the given local and key. See the I18n API documentation for details.
22
+ @tree_cache = nil
23
+ @cache_version = nil
24
+ end # Translates the given local and key. See the I18n API documentation for details.
24
25
  #
25
26
  # @return [Object] the translated key (usually a String)
26
27
  def translate(locale, key, options = {})
28
+ # I18nの標準処理に任せる(内部でlookupが呼ばれる)
27
29
  content = super(locale, key, options)
28
- if CopyTunerClient.configuration.inline_translation
29
- content = (content.is_a?(Array) ? content : key.to_s)
30
- end
31
30
 
32
- if !CopyTunerClient.configuration.html_escape
31
+ return content if content.nil? || content.is_a?(Hash)
32
+
33
+ # HTML escapeの処理(ツリー構造のHashは除く)
34
+ if CopyTunerClient.configuration.html_escape
35
+ content
36
+ else
33
37
  # Backward compatible
34
38
  content.respond_to?(:html_safe) ? content.html_safe : content
35
- else
36
- content
37
39
  end
38
40
  end
39
41
 
40
- # Returns locales availabile for this CopyTuner project.
42
+ # Returns locales available for this CopyTuner project.
41
43
  # @return [Array<String>] available locales
42
44
  def available_locales
43
45
  return @available_locales if defined?(@available_locales)
@@ -71,11 +73,45 @@ module CopyTunerClient
71
73
  CopyTunerClient::configuration.ignored_key_handler.call(IgnoredKey.new("Ignored key: #{key_without_locale}"))
72
74
  end
73
75
 
74
- content = cache[key_with_locale] || super
75
- cache[key_with_locale] = nil if content.nil?
76
+ # NOTE: ハッシュ化した場合に削除されるキーに対応するため、最初に完全一致をチェック(旧クライアントの動作を維持)
77
+ # 例: `en.test.key` `en.test.key.conflict` のように別のキーで上書きされている場合の対応
78
+ exact_match = cache[key_with_locale]
79
+ if exact_match
80
+ return exact_match
81
+ end
82
+
83
+ ensure_tree_cache_current
84
+ tree_result = lookup_in_tree_cache(parts)
85
+ return tree_result if tree_result
86
+
87
+ content = super
88
+
89
+ if content.nil?
90
+ cache[key_with_locale] = nil
91
+ end
92
+
76
93
  content
77
94
  end
78
95
 
96
+ def ensure_tree_cache_current
97
+ current_version = cache.version
98
+ # ETag が nil の場合(初回ダウンロード前)や変更があった場合のみ更新
99
+ # 初回は @cache_version が nil なので、必ず更新される
100
+ if @cache_version != current_version || @tree_cache.nil?
101
+ @tree_cache = cache.to_tree_hash.deep_symbolize_keys
102
+ @cache_version = current_version
103
+ end
104
+ end
105
+
106
+ def lookup_in_tree_cache(keys)
107
+ return nil if @tree_cache.nil?
108
+
109
+ symbol_keys = keys.map(&:to_sym)
110
+ result = @tree_cache.dig(*symbol_keys)
111
+
112
+ result.is_a?(Hash) ? result : nil
113
+ end
114
+
79
115
  def store_item(locale, data, scope = [])
80
116
  if data.respond_to?(:to_hash)
81
117
  data.to_hash.each do |key, value|
@@ -1,6 +1,6 @@
1
1
  module CopyTunerClient
2
2
  # Client version
3
- VERSION = '1.0.0'.freeze
3
+ VERSION = '1.1.0'.freeze
4
4
 
5
5
  # API version being used to communicate with the server
6
6
  API_VERSION = '2.0'.freeze
@@ -1,6 +1,6 @@
1
1
  require 'spec_helper'
2
2
 
3
- describe CopyTunerClient::Cache do
3
+ describe 'CopyTunerClient::Cache' do
4
4
  let(:client) { FakeClient.new }
5
5
 
6
6
  def build_cache(ready: false, **config)
@@ -12,7 +12,7 @@ describe CopyTunerClient::Cache do
12
12
  cache
13
13
  end
14
14
 
15
- it 'provides access to downloaded data' do
15
+ it 'ダウンロードしたデータにアクセスできること' do
16
16
  client['en.test.key'] = 'expected'
17
17
  client['en.test.other_key'] = 'expected'
18
18
 
@@ -24,7 +24,7 @@ describe CopyTunerClient::Cache do
24
24
  expect(cache.keys).to match_array(%w[en.test.key en.test.other_key])
25
25
  end
26
26
 
27
- it 'exclude data if exclude_key_regexp is set' do
27
+ it 'exclude_key_regexpが設定されている場合、該当データを除外すること' do
28
28
  cache = build_cache(exclude_key_regexp: /^en\.test\.other_key$/)
29
29
  cache['en.test.key'] = 'expected'
30
30
  cache['en.test.other_key'] = 'expected'
@@ -34,13 +34,13 @@ describe CopyTunerClient::Cache do
34
34
  expect(cache.queued.keys).to match_array(%w[en.test.key])
35
35
  end
36
36
 
37
- it "doesn't upload without changes" do
37
+ it '変更がない場合はアップロードしないこと' do
38
38
  cache = build_cache
39
39
  cache.flush
40
40
  expect(client).not_to be_uploaded
41
41
  end
42
42
 
43
- it "Don't upload incorrect key" do
43
+ it '不正なキーはアップロードしないこと' do
44
44
  cache = build_cache
45
45
  cache['ja'] = 'incorrect key'
46
46
 
@@ -48,7 +48,7 @@ describe CopyTunerClient::Cache do
48
48
  expect(client).not_to be_uploaded
49
49
  end
50
50
 
51
- it 'uploads changes when flushed' do
51
+ it '変更があればflush時にアップロードすること' do
52
52
  cache = build_cache
53
53
  cache['test.key'] = 'test value'
54
54
 
@@ -57,7 +57,7 @@ describe CopyTunerClient::Cache do
57
57
  expect(client.uploaded).to eq({ 'test.key' => 'test value' })
58
58
  end
59
59
 
60
- it 'uploads empties when nil is assigned' do
60
+ it 'nilを代入した場合は空文字でアップロードすること' do
61
61
  cache = build_cache
62
62
  cache['test.key'] = nil
63
63
 
@@ -66,7 +66,7 @@ describe CopyTunerClient::Cache do
66
66
  expect(client.uploaded).to eq({ 'test.key' => '' })
67
67
  end
68
68
 
69
- it 'upload without locale filter' do
69
+ it 'ロケールフィルタなしでアップロードできること' do
70
70
  cache = build_cache
71
71
  cache['en.test.key'] = 'uploaded en'
72
72
  cache['ja.test.key'] = 'uploaded ja'
@@ -76,7 +76,7 @@ describe CopyTunerClient::Cache do
76
76
  expect(client.uploaded).to eq({ 'en.test.key' => 'uploaded en', 'ja.test.key' => 'uploaded ja' })
77
77
  end
78
78
 
79
- it 'upload with locale filter' do
79
+ it 'ロケールフィルタありでアップロードできること' do
80
80
  cache = build_cache(locales: %(en))
81
81
  cache['en.test.key'] = 'uploaded'
82
82
  cache['ja.test.key'] = 'not uploaded'
@@ -86,7 +86,7 @@ describe CopyTunerClient::Cache do
86
86
  expect(client.uploaded).to eq({ 'en.test.key' => 'uploaded' })
87
87
  end
88
88
 
89
- it 'downloads changes' do
89
+ it 'ダウンロードで値を取得できること' do
90
90
  client['test.key'] = 'test value'
91
91
  cache = build_cache
92
92
 
@@ -95,7 +95,7 @@ describe CopyTunerClient::Cache do
95
95
  expect(cache['test.key']).to eq('test value')
96
96
  end
97
97
 
98
- it 'downloads and uploads when synced' do
98
+ it 'syncでダウンロードとアップロードが両方行われること' do
99
99
  cache = build_cache
100
100
  client['test.key'] = 'test value'
101
101
  cache['other.key'] = 'other value'
@@ -106,7 +106,7 @@ describe CopyTunerClient::Cache do
106
106
  expect(cache['test.key']).to eq('test value')
107
107
  end
108
108
 
109
- it 'download included empty keys' do
109
+ it '空文字のキーはダウンロード時にnilになること' do
110
110
  client['en.test.key'] = 'test value'
111
111
  client['en.test.empty'] = ''
112
112
  cache = build_cache
@@ -120,7 +120,7 @@ describe CopyTunerClient::Cache do
120
120
  expect(cache.queued).to be_empty
121
121
  end
122
122
 
123
- it 'Do not upload downloaded keys' do
123
+ it 'ダウンロードしたキーはアップロード対象にならないこと' do
124
124
  client['en.test.key'] = 'test value'
125
125
  cache = build_cache
126
126
 
@@ -130,7 +130,7 @@ describe CopyTunerClient::Cache do
130
130
  expect(cache.queued).to be_empty
131
131
  end
132
132
 
133
- it 'handles connection errors when flushing' do
133
+ it 'flush時に接続エラーが発生した場合はエラーログを出力すること' do
134
134
  failure = 'server is napping'
135
135
  logger = FakeLogger.new
136
136
  expect(client).to receive(:upload).and_raise(CopyTunerClient::ConnectionError.new(failure))
@@ -142,7 +142,7 @@ describe CopyTunerClient::Cache do
142
142
  expect(logger).to have_entry(:error, failure)
143
143
  end
144
144
 
145
- it 'handles connection errors when downloading' do
145
+ it 'download時に接続エラーが発生した場合はエラーログを出力すること' do
146
146
  failure = 'server is napping'
147
147
  logger = FakeLogger.new
148
148
  expect(client).to receive(:download).and_raise(CopyTunerClient::ConnectionError.new(failure))
@@ -153,7 +153,7 @@ describe CopyTunerClient::Cache do
153
153
  expect(logger).to have_entry(:error, failure)
154
154
  end
155
155
 
156
- it 'blocks until the first download is complete' do
156
+ it '最初のダウンロードが完了するまでブロックすること' do
157
157
  logger = FakeLogger.new
158
158
  expect(logger).to receive(:flush)
159
159
  client.delay = true
@@ -172,7 +172,7 @@ describe CopyTunerClient::Cache do
172
172
  expect(t_wait.join(1)).not_to be_nil
173
173
  end
174
174
 
175
- it "doesn't block before downloading" do
175
+ it 'ダウンロード前はブロックしないこと' do
176
176
  logger = FakeLogger.new
177
177
  cache = build_cache(logger: logger)
178
178
 
@@ -188,7 +188,7 @@ describe CopyTunerClient::Cache do
188
188
  expect(logger).not_to have_entry(:info, 'Waiting for first download')
189
189
  end
190
190
 
191
- it "doesn't return blank copy" do
191
+ it '空文字のコピーは返さないこと' do
192
192
  client['en.test.key'] = ''
193
193
  cache = build_cache
194
194
 
@@ -197,13 +197,13 @@ describe CopyTunerClient::Cache do
197
197
  expect(cache['en.test.key']).to be_nil
198
198
  end
199
199
 
200
- describe 'given locked mutex' do
200
+ describe 'ミューテックスがロックされている場合' do
201
201
  RSpec::Matchers.define :finish_after_unlocking do |mutex|
202
202
  match do |thread|
203
203
  sleep(0.1)
204
204
 
205
205
  if thread.status === false
206
- violated('finished before unlocking')
206
+ violated('アンロック前に終了してしまった')
207
207
  else
208
208
  mutex.unlock
209
209
  sleep(0.1)
@@ -211,7 +211,7 @@ describe CopyTunerClient::Cache do
211
211
  if thread.status === false
212
212
  true
213
213
  else
214
- violated('still running after unlocking')
214
+ violated('アンロック後もスレッドが終了しない')
215
215
  end
216
216
  end
217
217
  end
@@ -234,20 +234,20 @@ describe CopyTunerClient::Cache do
234
234
  allow(Mutex).to receive(:new).and_return(mutex)
235
235
  end
236
236
 
237
- it 'synchronizes read access to keys between threads' do
237
+ it 'スレッド間でキーの読み取りアクセスが同期されること' do
238
238
  expect(Thread.new { cache['test.key'] }).to finish_after_unlocking(mutex)
239
239
  end
240
240
 
241
- it 'synchronizes read access to the key list between threads' do
241
+ it 'スレッド間でキーリストの読み取りアクセスが同期されること' do
242
242
  expect(Thread.new { cache.keys }).to finish_after_unlocking(mutex)
243
243
  end
244
244
 
245
- it 'synchronizes write access to keys between threads' do
245
+ it 'スレッド間でキーの書き込みアクセスが同期されること' do
246
246
  expect(Thread.new { cache['test.key'] = 'value' }).to finish_after_unlocking(mutex)
247
247
  end
248
248
  end
249
249
 
250
- it 'flushes from the top level' do
250
+ it 'トップレベルからflushできること' do
251
251
  cache = build_cache
252
252
  CopyTunerClient.configure do |config|
253
253
  config.cache = cache
@@ -257,6 +257,114 @@ describe CopyTunerClient::Cache do
257
257
  CopyTunerClient.flush
258
258
  end
259
259
 
260
+ describe '#to_tree_hash' do
261
+ subject { cache.to_tree_hash }
262
+
263
+ let(:cache) do
264
+ cache = build_cache
265
+ cache.download
266
+ cache
267
+ end
268
+
269
+ it 'データがない場合は空ハッシュを返すこと' do
270
+ is_expected.to eq({})
271
+ end
272
+
273
+ context 'フラットなキーの場合' do
274
+ before do
275
+ client['ja.views.hoge'] = 'test'
276
+ client['ja.views.fuga'] = 'test2'
277
+ client['en.hello'] = 'world'
278
+ end
279
+
280
+ it 'ツリー構造に変換されること' do
281
+ is_expected.to eq({
282
+ 'ja' => {
283
+ 'views' => {
284
+ 'hoge' => 'test',
285
+ 'fuga' => 'test2'
286
+ }
287
+ },
288
+ 'en' => {
289
+ 'hello' => 'world'
290
+ }
291
+ })
292
+ end
293
+ end
294
+
295
+ context '複雑なネスト構造の場合' do
296
+ before do
297
+ client['ja.views.users.index'] = 'user index'
298
+ client['ja.views.users.show'] = 'user show'
299
+ client['ja.views.posts.index'] = 'post index'
300
+ client['en.common.buttons.save'] = 'Save'
301
+ end
302
+
303
+ it '正しいツリー構造になること' do
304
+ is_expected.to eq({
305
+ 'ja' => {
306
+ 'views' => {
307
+ 'users' => {
308
+ 'index' => 'user index',
309
+ 'show' => 'user show'
310
+ },
311
+ 'posts' => {
312
+ 'index' => 'post index'
313
+ }
314
+ }
315
+ },
316
+ 'en' => {
317
+ 'common' => {
318
+ 'buttons' => {
319
+ 'save' => 'Save'
320
+ }
321
+ }
322
+ }
323
+ })
324
+ end
325
+ end
326
+ end
327
+
328
+ describe '#version' do
329
+ it 'クライアントのetagを返すこと(効率的なバージョンチェック)' do
330
+ cache = build_cache
331
+ client_instance = cache.send(:client)
332
+
333
+ # ETag が設定されている場合
334
+ client_instance.etag = '"abc123"'
335
+ expect(cache.version).to eq('"abc123"')
336
+
337
+ # ETag が変更された場合
338
+ client_instance.etag = '"def456"'
339
+ expect(cache.version).to eq('"def456"')
340
+ end
341
+
342
+ it 'etagがnilの場合も正常に動作すること' do
343
+ cache = build_cache
344
+ client_instance = cache.send(:client)
345
+ client_instance.etag = nil
346
+
347
+ expect(cache.version).to be_nil
348
+ end
349
+
350
+ it '大量のキャッシュでも高速にバージョン取得できること(keyのhashではなくetagを使うため)' do
351
+ cache = build_cache
352
+
353
+ # 大量のキーを追加
354
+ 1000.times do |i|
355
+ cache.instance_variable_get(:@blurbs)["ja.category#{i % 10}.item#{i}"] = "value#{i}"
356
+ end
357
+
358
+ # version メソッドが etag を使用しているため高速(keys.hashだと低速)
359
+ start_time = Time.now
360
+ 1000.times { cache.version }
361
+ end_time = Time.now
362
+
363
+ # 10ms 以下で完了することを確認
364
+ expect((end_time - start_time) * 1000).to be < 10
365
+ end
366
+ end
367
+
260
368
  describe '#export' do
261
369
  subject { cache.export }
262
370
 
@@ -266,7 +374,7 @@ describe CopyTunerClient::Cache do
266
374
  cache
267
375
  end
268
376
 
269
- it 'can be invoked from the top-level constant' do
377
+ it 'トップレベル定数から呼び出せること' do
270
378
  CopyTunerClient.configure do |config|
271
379
  config.cache = cache
272
380
  end
@@ -274,11 +382,11 @@ describe CopyTunerClient::Cache do
274
382
  CopyTunerClient.export
275
383
  end
276
384
 
277
- it 'returns no yaml with no blurb keys' do
385
+ it 'blurbキーがない場合はyamlを返さないこと' do
278
386
  is_expected.to eq nil
279
387
  end
280
388
 
281
- context 'with single-level blurb keys' do
389
+ context '1階層のblurbキーがある場合' do
282
390
  before do
283
391
  client['key'] = 'test value'
284
392
  client['other_key'] = 'other test value'
@@ -1,6 +1,6 @@
1
1
  require 'spec_helper'
2
2
 
3
- describe CopyTunerClient do
3
+ describe 'CopyTunerClient' do
4
4
  let(:download_cache_dir) { Pathname.new(Dir.mktmpdir('copy_tuner_client')) }
5
5
 
6
6
  after do
@@ -27,7 +27,7 @@ describe CopyTunerClient do
27
27
  build_client(config)
28
28
  end
29
29
 
30
- describe 'opening a connection' do
30
+ describe 'コネクションのオープン' do
31
31
  let(:config) { CopyTunerClient::Configuration.new }
32
32
  let(:http) { Net::HTTP.new(config.host, config.port) }
33
33
 
@@ -35,21 +35,21 @@ describe CopyTunerClient do
35
35
  allow(Net::HTTP).to receive(:new).and_return(http)
36
36
  end
37
37
 
38
- it 'should timeout when connecting' do
38
+ it '接続時のタイムアウトが設定されていること' do
39
39
  project = add_project
40
40
  client = build_client(:api_key => project.api_key, :http_open_timeout => 4)
41
41
  client.download { |ignore| }
42
42
  expect(http.open_timeout).to eq(4)
43
43
  end
44
44
 
45
- it 'should timeout when reading' do
45
+ it '読み込み時のタイムアウトが設定されていること' do
46
46
  project = add_project
47
47
  client = build_client(:api_key => project.api_key, :http_read_timeout => 4)
48
48
  client.download { |ignore| }
49
49
  expect(http.read_timeout).to eq(4)
50
50
  end
51
51
 
52
- it 'uses verified ssl when secure' do
52
+ it 'secureがtrueの場合はSSL検証付きで接続すること' do
53
53
  project = add_project
54
54
  client = build_client(:api_key => project.api_key, :secure => true)
55
55
  client.download { |ignore| }
@@ -57,14 +57,14 @@ describe CopyTunerClient do
57
57
  expect(http.verify_mode).to eq(OpenSSL::SSL::VERIFY_PEER)
58
58
  end
59
59
 
60
- it 'does not use ssl when insecure' do
60
+ it 'secureがfalseの場合はSSLを使用しないこと' do
61
61
  project = add_project
62
62
  client = build_client(:api_key => project.api_key, :secure => false)
63
63
  client.download { |ignore| }
64
64
  expect(http.use_ssl?).to eq(false)
65
65
  end
66
66
 
67
- it 'wraps HTTP errors with ConnectionError' do
67
+ it 'HTTPエラーをConnectionErrorでラップすること' do
68
68
  errors = [
69
69
  Timeout::Error.new,
70
70
  Errno::EINVAL.new,
@@ -89,30 +89,30 @@ describe CopyTunerClient do
89
89
  end
90
90
  end
91
91
 
92
- it 'handles 500 errors from downloads with ConnectionError' do
92
+ it 'ダウンロード時に500エラーが発生した場合はConnectionErrorになること' do
93
93
  client = build_client(:api_key => 'raise_error')
94
94
  expect { client.download { |ignore| } }.
95
95
  to raise_error(CopyTunerClient::ConnectionError)
96
96
  end
97
97
 
98
- it 'handles 500 errors from uploads with ConnectionError' do
98
+ it 'アップロード時に500エラーが発生した場合はConnectionErrorになること' do
99
99
  client = build_client(:api_key => 'raise_error')
100
100
  expect { client.upload({}) }.to raise_error(CopyTunerClient::ConnectionError)
101
101
  end
102
102
 
103
- it 'handles 404 errors from downloads with ConnectionError' do
103
+ it 'ダウンロード時に404エラーが発生した場合はInvalidApiKeyになること' do
104
104
  client = build_client(:api_key => 'bogus')
105
105
  expect { client.download { |ignore| } }.
106
106
  to raise_error(CopyTunerClient::InvalidApiKey)
107
107
  end
108
108
 
109
- it 'handles 404 errors from uploads with ConnectionError' do
109
+ it 'アップロード時に404エラーが発生した場合はInvalidApiKeyになること' do
110
110
  client = build_client(:api_key => 'bogus')
111
111
  expect { client.upload({}) }.to raise_error(CopyTunerClient::InvalidApiKey)
112
112
  end
113
113
  end
114
114
 
115
- it 'downloads published blurbs for an existing project' do
115
+ it '既存プロジェクトのpublishedなblurbをダウンロードできること' do
116
116
  project = add_project
117
117
  project.update({
118
118
  'draft' => {
@@ -135,14 +135,14 @@ describe CopyTunerClient do
135
135
  })
136
136
  end
137
137
 
138
- it 'logs that it performed a download' do
138
+ it 'ダウンロードを実行したことをログに出力すること' do
139
139
  logger = FakeLogger.new
140
140
  client = build_client_with_project(:logger => logger)
141
141
  client.download { |ignore| }
142
142
  expect(logger).to have_entry(:info, 'Downloaded translations')
143
143
  end
144
144
 
145
- it 'downloads draft blurbs for an existing project' do
145
+ it '既存プロジェクトのdraftなblurbをダウンロードできること' do
146
146
  project = add_project
147
147
  project.update({
148
148
  'draft' => {
@@ -165,7 +165,7 @@ describe CopyTunerClient do
165
165
  })
166
166
  end
167
167
 
168
- it "handles a 304 response when downloading" do
168
+ it '304レスポンス時は2回目以降yieldされないこと' do
169
169
  project = add_project
170
170
  project.update('draft' => { 'key.one' => "expected one" })
171
171
  logger = FakeLogger.new
@@ -182,7 +182,7 @@ describe CopyTunerClient do
182
182
  expect(logger).to have_entry(:info, "No new translations")
183
183
  end
184
184
 
185
- it "uploads defaults for missing blurbs in an existing project" do
185
+ it '既存プロジェクトに存在しないblurbはアップロードされること' do
186
186
  project = add_project
187
187
 
188
188
  blurbs = {
@@ -196,14 +196,14 @@ describe CopyTunerClient do
196
196
  expect(project.reload.draft).to eq(blurbs)
197
197
  end
198
198
 
199
- it "logs that it performed an upload" do
199
+ it 'アップロードを実行したことをログに出力すること' do
200
200
  logger = FakeLogger.new
201
201
  client = build_client_with_project(:logger => logger)
202
202
  client.upload({})
203
203
  expect(logger).to have_entry(:info, "Uploaded missing translations")
204
204
  end
205
205
 
206
- it "deploys from the top-level constant" do
206
+ it 'トップレベル定数からdeployできること' do
207
207
  client = build_client
208
208
  allow(client).to receive(:download)
209
209
  CopyTunerClient.configure do |config|
@@ -214,7 +214,7 @@ describe CopyTunerClient do
214
214
  CopyTunerClient.deploy
215
215
  end
216
216
 
217
- it "deploys" do
217
+ it 'deployが実行できること' do
218
218
  project = add_project
219
219
  project.update({
220
220
  'draft' => {
@@ -238,7 +238,41 @@ describe CopyTunerClient do
238
238
  expect(logger).to have_entry(:info, "Deployed")
239
239
  end
240
240
 
241
- it "handles deploy errors" do
241
+ it 'deploy時にエラーが発生した場合は例外が発生すること' do
242
242
  expect { build_client.deploy }.to raise_error(CopyTunerClient::InvalidApiKey)
243
243
  end
244
+
245
+ describe '#etag' do
246
+ it 'etagが読み取り可能な属性として公開されていること' do
247
+ client = build_client
248
+ expect(client).to respond_to(:etag)
249
+ end
250
+
251
+ it '初期状態ではetagがnilであること' do
252
+ client = build_client
253
+ expect(client.etag).to be_nil
254
+ end
255
+
256
+ it 'ダウンロード成功時にetagが更新されること' do
257
+ project = add_project
258
+ client = build_client(:api_key => project.api_key)
259
+
260
+ # モックでETagを設定
261
+ response = Net::HTTPSuccess.new('1.1', '200', 'OK')
262
+ allow(response).to receive(:body).and_return('{}')
263
+ allow(response).to receive(:[]).with('ETag').and_return('"abc123"')
264
+
265
+ http = double('http')
266
+ allow(Net::HTTP).to receive(:new).and_return(http)
267
+ allow(http).to receive(:open_timeout=)
268
+ allow(http).to receive(:read_timeout=)
269
+ allow(http).to receive(:use_ssl=)
270
+ allow(http).to receive(:verify_mode=)
271
+ allow(http).to receive(:ca_file=)
272
+ allow(http).to receive(:request).and_return(response)
273
+
274
+ client.download { |blurbs| }
275
+ expect(client.etag).to eq('"abc123"')
276
+ end
277
+ end
244
278
  end
@@ -1,7 +1,35 @@
1
1
  require 'spec_helper'
2
2
 
3
- describe CopyTunerClient::I18nBackend do
4
- let(:cache) { {} }
3
+ describe 'CopyTunerClient::I18nBackend' do
4
+ # テスト用のキャッシュクラス:既存のHashインターフェースを維持しつつ新機能をサポート
5
+ class TestCache < Hash
6
+ def initialize(initial_etag = 'test-etag-1')
7
+ super()
8
+ @test_etag = initial_etag
9
+ end
10
+
11
+ def version
12
+ @test_etag
13
+ end
14
+
15
+ def to_tree_hash
16
+ CopyTunerClient::DottedHash.to_h(self)
17
+ end
18
+
19
+ def wait_for_download
20
+ # テスト用のスタブメソッド
21
+ end
22
+
23
+ def etag=(value)
24
+ @test_etag = value
25
+ end
26
+
27
+ def etag
28
+ @test_etag
29
+ end
30
+ end
31
+
32
+ let(:cache) { TestCache.new }
5
33
 
6
34
  def build_backend
7
35
  backend = CopyTunerClient::I18nBackend.new(cache)
@@ -11,25 +39,24 @@ describe CopyTunerClient::I18nBackend do
11
39
 
12
40
  before do
13
41
  @default_backend = I18n.backend
14
- allow(cache).to receive(:wait_for_download)
15
42
  end
16
43
 
17
44
  after { I18n.backend = @default_backend }
18
45
 
19
46
  subject { build_backend }
20
47
 
21
- it "reloads locale files and waits for the download to complete" do
48
+ it 'ロケールファイルをリロードし、ダウンロード完了まで待機すること' do
22
49
  expect(I18n).to receive(:load_path).and_return([])
23
- expect(cache).to receive(:wait_for_download)
50
+ # wait_for_downloadはTestCacheクラス内で呼ばれる
24
51
  subject.reload!
25
52
  subject.translate('en', 'test.key', :default => 'something')
26
53
  end
27
54
 
28
- it "includes the base i18n backend" do
55
+ it 'i18nのBaseバックエンドを継承していること' do
29
56
  is_expected.to be_kind_of(I18n::Backend::Base)
30
57
  end
31
58
 
32
- it "looks up a key in cache" do
59
+ it 'キャッシュからキーを検索できること' do
33
60
  value = 'hello'
34
61
  cache['en.prefix.test.key'] = value
35
62
 
@@ -38,7 +65,7 @@ describe CopyTunerClient::I18nBackend do
38
65
  expect(backend.translate('en', 'test.key', :scope => 'prefix')).to eq(value)
39
66
  end
40
67
 
41
- it "finds available locales from locale files and cache" do
68
+ it 'ロケールファイルとキャッシュから利用可能なロケールを取得できること' do
42
69
  allow(YAML).to receive(:unsafe_load_file).and_return({ 'es' => { 'key' => 'value' } })
43
70
  allow(I18n).to receive(:load_path).and_return(["test.yml"])
44
71
 
@@ -48,7 +75,7 @@ describe CopyTunerClient::I18nBackend do
48
75
  expect(subject.available_locales).to match_array([:en, :es, :fr])
49
76
  end
50
77
 
51
- it "queues missing keys with default" do
78
+ it 'default付きで未登録キーをキューイングすること' do
52
79
  default = 'default value'
53
80
 
54
81
  expect(subject.translate('en', 'test.key', :default => default)).to eq(default)
@@ -56,7 +83,7 @@ describe CopyTunerClient::I18nBackend do
56
83
  expect(cache['en.test.key']).to eq(default)
57
84
  end
58
85
 
59
- it "queues missing keys with default string in an array" do
86
+ it 'defaultが配列(文字列1つ)の場合も未登録キーをキューイングすること' do
60
87
  default = 'default value'
61
88
 
62
89
  expect(subject.translate('en', 'test.key', :default => [default])).to eq(default)
@@ -64,7 +91,7 @@ describe CopyTunerClient::I18nBackend do
64
91
  expect(cache['en.test.key']).to eq(default)
65
92
  end
66
93
 
67
- it "queues missing keys without default" do
94
+ it 'defaultなしで未登録キーをキューイングすること' do
68
95
  expect { subject.translate('en', 'test.key') }.
69
96
  to throw_symbol(:exception)
70
97
 
@@ -72,7 +99,7 @@ describe CopyTunerClient::I18nBackend do
72
99
  expect(cache['en.test.key']).to be_nil
73
100
  end
74
101
 
75
- it "queues missing keys with scope" do
102
+ it 'scope付きで未登録キーをキューイングすること' do
76
103
  default = 'default value'
77
104
 
78
105
  expect(subject.translate('en', 'key', :default => default, :scope => ['test'])).
@@ -81,7 +108,7 @@ describe CopyTunerClient::I18nBackend do
81
108
  expect(cache['en.test.key']).to eq(default)
82
109
  end
83
110
 
84
- it "does not queues missing keys with a symbol of default" do
111
+ it 'defaultがシンボルの場合は未登録キーをキューイングしないこと' do
85
112
  cache['en.key.one'] = "Expected"
86
113
 
87
114
  expect(subject.translate('en', 'key.three', :default => :"key.one")).to eq 'Expected'
@@ -92,7 +119,7 @@ describe CopyTunerClient::I18nBackend do
92
119
  expect(subject.translate('en', 'key.three', :default => :"key.one")).to eq 'Expected'
93
120
  end
94
121
 
95
- it "does not queues missing keys with an array of default" do
122
+ it 'defaultが配列(シンボル含む)の場合は未登録キーをキューイングしないこと' do
96
123
  cache['en.key.one'] = "Expected"
97
124
 
98
125
  expect(subject.translate('en', 'key.three', :default => [:"key.two", :"key.one"])).to eq 'Expected'
@@ -103,7 +130,7 @@ describe CopyTunerClient::I18nBackend do
103
130
  expect(subject.translate('en', 'key.three', :default => [:"key.two", :"key.one"])).to eq 'Expected'
104
131
  end
105
132
 
106
- it "queues missing keys with interpolation" do
133
+ it '補間付きで未登録キーをキューイングすること' do
107
134
  default = 'default %{interpolate}'
108
135
 
109
136
  expect(subject.translate('en', 'test.key', :default => default, :interpolate => 'interpolated')).to eq 'default interpolated'
@@ -111,20 +138,20 @@ describe CopyTunerClient::I18nBackend do
111
138
  expect(cache['en.test.key']).to eq 'default %{interpolate}'
112
139
  end
113
140
 
114
- it "dose not mark strings as html safe" do
141
+ it 'html safeを付与しないこと' do
115
142
  cache['en.test.key'] = FakeHtmlSafeString.new("Hello")
116
143
  backend = build_backend
117
144
  expect(backend.translate('en', 'test.key')).to_not be_html_safe
118
145
  end
119
146
 
120
- it "looks up an array of defaults" do
147
+ it 'defaultが配列の場合に順に検索できること' do
121
148
  cache['en.key.one'] = "Expected"
122
149
  backend = build_backend
123
150
  expect(backend.translate('en', 'key.three', :default => [:"key.two", :"key.one"])).
124
151
  to eq('Expected')
125
152
  end
126
153
 
127
- context "html_escape option is true" do
154
+ context 'html_escapeオプションがtrueの場合' do
128
155
  before do
129
156
  CopyTunerClient.configure do |configuration|
130
157
  configuration.html_escape = true
@@ -132,71 +159,71 @@ describe CopyTunerClient::I18nBackend do
132
159
  end
133
160
  end
134
161
 
135
- it "do not marks strings as html safe" do
162
+ it 'html safeを付与しないこと' do
136
163
  cache['en.test.key'] = FakeHtmlSafeString.new("Hello")
137
164
  backend = build_backend
138
165
  expect(backend.translate('en', 'test.key')).not_to be_html_safe
139
166
  end
140
167
  end
141
168
 
142
- context 'non-string key' do
143
- it 'Not to be registered in the cache' do
169
+ context '非文字列キーの場合' do
170
+ it 'キャッシュに登録されないこと' do
144
171
  expect { subject.translate('en', {}) }.to throw_symbol(:exception)
145
172
  expect(cache).not_to have_key 'en.{}'
146
173
  end
147
174
  end
148
175
 
149
- describe "with stored translations" do
176
+ describe 'store_translations利用時' do
150
177
  subject { build_backend }
151
178
 
152
- it "uses stored translations as a default" do
179
+ it 'store_translationsで登録した値をdefaultとして利用できること' do
153
180
  subject.store_translations('en', 'test' => { 'key' => 'Expected' })
154
181
  expect(subject.translate('en', 'test.key', :default => 'Unexpected')).
155
182
  to include('Expected')
156
183
  expect(cache['en.test.key']).to eq('Expected')
157
184
  end
158
185
 
159
- it "preserves interpolation markers in the stored translation" do
186
+ it '補間マーカーを保持したまま保存できること' do
160
187
  subject.store_translations('en', 'test' => { 'key' => '%{interpolate}' })
161
188
  expect(subject.translate('en', 'test.key', :interpolate => 'interpolated')).
162
189
  to include('interpolated')
163
190
  expect(cache['en.test.key']).to eq('%{interpolate}')
164
191
  end
165
192
 
166
- it "uses the default if the stored translations don't have the key" do
193
+ it 'store_translationsでキーがなければdefaultを利用すること' do
167
194
  expect(subject.translate('en', 'test.key', :default => 'Expected')).
168
195
  to include('Expected')
169
196
  end
170
197
 
171
- it "uses the cached key when present" do
198
+ it 'キャッシュにキーがあればそちらを優先すること' do
172
199
  subject.store_translations('en', 'test' => { 'key' => 'Unexpected' })
173
200
  cache['en.test.key'] = 'Expected'
174
201
  expect(subject.translate('en', 'test.key', :default => 'default')).
175
202
  to include('Expected')
176
203
  end
177
204
 
178
- it "stores a nested hash" do
205
+ it 'ネストしたハッシュを保存できること' do
179
206
  nested = { :nested => 'value' }
180
207
  subject.store_translations('en', 'key' => nested)
181
208
  expect(subject.translate('en', 'key', :default => 'Unexpected')).to eq(nested)
182
209
  expect(cache['en.key.nested']).to eq('value')
183
210
  end
184
211
 
185
- it "returns an array directly without storing" do
212
+ it '配列はそのまま返しキャッシュしないこと' do
186
213
  array = ['value']
187
214
  subject.store_translations('en', 'key' => array)
188
215
  expect(subject.translate('en', 'key', :default => 'Unexpected')).to eq(array)
189
216
  expect(cache['en.key']).to be_nil
190
217
  end
191
218
 
192
- it "looks up an array of defaults" do
219
+ it 'defaultが配列の場合に順に検索できること' do
193
220
  subject.store_translations('en', 'key' => { 'one' => 'Expected' })
194
221
  expect(subject.translate('en', 'key.three', :default => [:"key.two", :"key.one"])).
195
222
  to include('Expected')
196
223
  end
197
224
  end
198
225
 
199
- describe "with a backend using fallbacks" do
226
+ describe 'Fallbacks利用時' do
200
227
  subject { build_backend }
201
228
 
202
229
  before do
@@ -205,7 +232,7 @@ describe CopyTunerClient::I18nBackend do
205
232
  end
206
233
  end
207
234
 
208
- it "queues missing keys with blank string" do
235
+ it 'defaultとFallbacks併用時はキャッシュにデフォルト値を入れないこと' do
209
236
  default = 'default value'
210
237
  expect(subject.translate('en', 'test.key', :default => default)).to eq(default)
211
238
 
@@ -215,4 +242,181 @@ describe CopyTunerClient::I18nBackend do
215
242
  expect(cache['en.test.key']).to be_nil
216
243
  end
217
244
  end
245
+
246
+ describe 'ツリー構造のlookup' do
247
+ subject { build_backend }
248
+
249
+ context '完全一致が存在する場合' do
250
+ it 'ツリー構造より完全一致を優先すること' do
251
+ cache['ja.views.hoge'] = 'exact_value'
252
+ cache['ja.views.hoge.sub'] = 'sub_value'
253
+
254
+ result = subject.translate('ja', 'views.hoge')
255
+ expect(result).to eq('exact_value')
256
+ end
257
+ end
258
+
259
+ context 'ツリー構造のみ存在する場合' do
260
+ it '部分キーでツリー構造を返すこと' do
261
+ cache['ja.views.hoge'] = 'test'
262
+ cache['ja.views.fuga'] = 'test2'
263
+ cache['ja.other.key'] = 'other'
264
+
265
+ result = subject.translate('ja', 'views')
266
+ expect(result).to eq({
267
+ :hoge => 'test',
268
+ :fuga => 'test2'
269
+ })
270
+ end
271
+
272
+ it '存在しない部分キーはnilを返すこと' do
273
+ cache['ja.views.hoge'] = 'test'
274
+
275
+ result = subject.translate('ja', 'nonexistent', default: nil)
276
+ expect(result).to be_nil
277
+ end
278
+
279
+ it 'ネストしたツリー構造を返すこと' do
280
+ cache['ja.views.users.index'] = 'user index'
281
+ cache['ja.views.users.show'] = 'user show'
282
+ cache['ja.views.posts.index'] = 'post index'
283
+
284
+ result = subject.translate('ja', 'views')
285
+ expect(result).to eq({
286
+ :users => {
287
+ :index => 'user index',
288
+ :show => 'user show'
289
+ },
290
+ :posts => {
291
+ :index => 'post index'
292
+ }
293
+ })
294
+ end
295
+ end
296
+
297
+ context '混在シナリオ' do
298
+ before do
299
+ cache['ja.views.hoge'] = 'exact_hoge'
300
+ cache['ja.views.hoge.sub'] = 'sub_value'
301
+ cache['ja.views.fuga.one'] = 'one'
302
+ cache['ja.views.fuga.two'] = 'two'
303
+ end
304
+
305
+ it '完全一致を正しく扱うこと' do
306
+ expect(subject.translate('ja', 'views.hoge')).to eq('exact_hoge')
307
+ end
308
+
309
+ it 'ツリー構造を正しく扱うこと' do
310
+ expect(subject.translate('ja', 'views.fuga')).to eq({
311
+ :one => 'one',
312
+ :two => 'two'
313
+ })
314
+ end
315
+
316
+ it 'より深い完全一致も正しく扱うこと' do
317
+ expect(subject.translate('ja', 'views.hoge.sub')).to eq('sub_value')
318
+ end
319
+ end
320
+
321
+ context 'ツリーキャッシュ管理' do
322
+ it '最初のlookupでツリーキャッシュを構築すること' do
323
+ cache['ja.views.hoge'] = 'test'
324
+ cache['ja.views.fuga'] = 'test2'
325
+
326
+ # 最初のlookupでツリーキャッシュが構築される
327
+ result = subject.translate('ja', 'views')
328
+ expect(result).to eq({
329
+ :hoge => 'test',
330
+ :fuga => 'test2'
331
+ })
332
+ end
333
+
334
+ it '2回目以降はツリーキャッシュを再利用すること' do
335
+ cache['ja.views.hoge'] = 'test'
336
+
337
+ # 1回目
338
+ subject.translate('ja', 'views')
339
+
340
+ # ツリーキャッシュの再構築が発生しないことを確認
341
+ expect(cache).not_to receive(:to_tree_hash)
342
+
343
+ # 2回目
344
+ subject.translate('ja', 'views')
345
+ end
346
+
347
+ it 'キャッシュバージョンが変わった場合はツリーキャッシュを再構築すること' do
348
+ cache['ja.views.hoge'] = 'test'
349
+ subject.translate('ja', 'views')
350
+
351
+ # ETag(バージョン)を変更してキャッシュを更新
352
+ cache.etag = '"new_etag"'
353
+
354
+ # 新しい値を追加
355
+ cache['ja.views.new'] = 'new value'
356
+ result = subject.translate('ja', 'views')
357
+ expect(result).to include(:new => 'new value')
358
+ end
359
+
360
+ it 'キャッシュバージョンがnilでも正常に動作すること' do
361
+ cache['ja.views.test'] = 'value'
362
+ cache.etag = nil
363
+
364
+ result = subject.translate('ja', 'views')
365
+ expect(result).to eq({ :test => 'value' })
366
+ end
367
+ end
368
+
369
+ context '大規模キャッシュ時のパフォーマンス' do
370
+ it 'etagバージョン管理でツリーキャッシュを効率的に扱うこと' do
371
+ # 大量のキャッシュエントリを追加
372
+ 1000.times do |i|
373
+ cache["ja.category#{i % 10}.item#{i}"] = "value#{i}"
374
+ end
375
+
376
+ # 初回のツリーキャッシュ構築
377
+ subject.translate('ja', 'category1')
378
+
379
+ # ETag が変わらない限り、再構築されない
380
+ expect(cache).not_to receive(:to_tree_hash)
381
+
382
+ # 複数回の lookup が高速で実行される
383
+ start_time = Time.now
384
+ 10.times { subject.translate('ja', 'category2') }
385
+ end_time = Time.now
386
+
387
+ # 10ms 以下で完了することを確認
388
+ expect((end_time - start_time) * 1000).to be < 10
389
+ end
390
+ end
391
+
392
+ context 'エッジケース' do
393
+ it '空キャッシュでも正常に動作すること' do
394
+ result = subject.translate('ja', 'views', default: nil)
395
+ expect(result).to be_nil
396
+ end
397
+
398
+ it '1階層のキーも正常に扱えること' do
399
+ cache['ja.simple'] = 'simple value'
400
+
401
+ result = subject.translate('ja', 'simple')
402
+ expect(result).to eq('simple value')
403
+ end
404
+
405
+ it 'ignored_keysの機能がツリーlookupでも維持されること' do
406
+ # ignored_keys 設定
407
+ allow(CopyTunerClient.configuration).to receive(:ignored_keys).and_return(['views.secret'])
408
+ handler = double('ignored_key_handler')
409
+ allow(CopyTunerClient.configuration).to receive(:ignored_key_handler).and_return(handler)
410
+
411
+ cache['ja.views.public'] = 'public'
412
+ cache['ja.views.secret'] = 'secret'
413
+
414
+ # ignored_key_handler が呼ばれることを確認
415
+ expect(handler).to receive(:call).with(instance_of(CopyTunerClient::IgnoredKey))
416
+
417
+ # ignored_keys が動作することを確認
418
+ subject.translate('ja', 'views.secret')
419
+ end
420
+ end
421
+ end
218
422
  end
@@ -7,10 +7,11 @@ class FakeClient
7
7
  @mutex = Mutex.new
8
8
  @cond = ConditionVariable.new
9
9
  @go = false
10
+ @etag = nil
10
11
  end
11
12
 
12
13
  attr_reader :uploaded, :uploads, :downloads
13
- attr_accessor :delay, :error
14
+ attr_accessor :delay, :error, :etag
14
15
 
15
16
  def []=(key, value)
16
17
  @data[key] = value
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: copy_tuner_client
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - SonicGarden
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-05-23 00:00:00.000000000 Z
11
+ date: 2025-07-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: i18n