copy_tuner_client 2.1.2 → 2.2.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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +5 -0
  3. data/.rubocop.yml +46 -1
  4. data/CLAUDE.md +1 -0
  5. data/Gemfile +1 -0
  6. data/copy_tuner_client.gemspec +3 -3
  7. data/gemfiles/8.0.gemfile +3 -3
  8. data/gemfiles/8.1.gemfile +3 -3
  9. data/gemfiles/main.gemfile +3 -3
  10. data/lib/copy_tuner_client/cache.rb +34 -27
  11. data/lib/copy_tuner_client/client.rb +35 -23
  12. data/lib/copy_tuner_client/configuration.rb +62 -40
  13. data/lib/copy_tuner_client/copyray/rewriter.rb +1 -1
  14. data/lib/copy_tuner_client/copyray.rb +1 -0
  15. data/lib/copy_tuner_client/copyray_middleware.rb +31 -24
  16. data/lib/copy_tuner_client/dotted_hash.rb +7 -7
  17. data/lib/copy_tuner_client/engine.rb +4 -6
  18. data/lib/copy_tuner_client/helper_extension.rb +58 -54
  19. data/lib/copy_tuner_client/i18n_backend.rb +12 -14
  20. data/lib/copy_tuner_client/i18n_compat.rb +2 -1
  21. data/lib/copy_tuner_client/poller.rb +3 -4
  22. data/lib/copy_tuner_client/prefixed_logger.rb +13 -12
  23. data/lib/copy_tuner_client/process_guard.rb +29 -26
  24. data/lib/copy_tuner_client/queue_with_timeout.rb +29 -19
  25. data/lib/copy_tuner_client/rails.rb +9 -8
  26. data/lib/copy_tuner_client/request_sync.rb +26 -24
  27. data/lib/copy_tuner_client/simple_form_extention.rb +10 -6
  28. data/lib/copy_tuner_client/translation_log.rb +10 -5
  29. data/lib/copy_tuner_client/version.rb +1 -1
  30. data/lib/copy_tuner_client.rb +4 -8
  31. data/lib/tasks/copy_tuner_client_tasks.rake +14 -16
  32. data/skills/copy-tuner-to-locales-migrate-prefix/scripts/migrate_prefix.rb +14 -7
  33. data/skills/copy-tuner-to-t-migrate/scripts/migrate_tt.rb +19 -17
  34. data/spec/copy_tuner_client/cache_spec.rb +63 -57
  35. data/spec/copy_tuner_client/client_spec.rb +82 -81
  36. data/spec/copy_tuner_client/configuration_spec.rb +223 -222
  37. data/spec/copy_tuner_client/copyray/marker_spec.rb +2 -1
  38. data/spec/copy_tuner_client/copyray/rewriter_spec.rb +15 -10
  39. data/spec/copy_tuner_client/copyray_middleware_spec.rb +9 -6
  40. data/spec/copy_tuner_client/copyray_spec.rb +8 -8
  41. data/spec/copy_tuner_client/dotted_hash_spec.rb +33 -33
  42. data/spec/copy_tuner_client/helper_extension_spec.rb +47 -44
  43. data/spec/copy_tuner_client/i18n_backend_spec.rb +144 -143
  44. data/spec/copy_tuner_client/poller_spec.rb +25 -24
  45. data/spec/copy_tuner_client/prefixed_logger_spec.rb +15 -12
  46. data/spec/copy_tuner_client/process_guard_spec.rb +27 -27
  47. data/spec/copy_tuner_client/request_sync_spec.rb +51 -58
  48. data/spec/copy_tuner_client/translation_log_spec.rb +38 -24
  49. data/spec/copy_tuner_client_spec.rb +5 -6
  50. data/spec/support/client_spec_helpers.rb +1 -1
  51. data/spec/support/defines_constants.rb +3 -34
  52. data/spec/support/fake_client.rb +1 -3
  53. data/spec/support/fake_copy_tuner_app.rb +54 -62
  54. data/spec/support/fake_html_safe_string.rb +2 -3
  55. data/spec/support/fake_logger.rb +22 -21
  56. data/spec/support/fake_passenger.rb +4 -6
  57. data/spec/support/fake_unicorn.rb +2 -4
  58. data/spec/support/middleware_stack.rb +3 -3
  59. data/spec/support/writing_cache.rb +4 -4
  60. metadata +1 -1
@@ -14,8 +14,9 @@ describe CopyTunerClient::Copyray::Rewriter do
14
14
  describe '.rewrite' do
15
15
  # NOTE: rewrite は [html, skipped] を返すが、既存テストの大半は html だけを検証する。
16
16
  # 1 回の呼び出しを let でメモ化して共有し、result は html、skipped は 2 要素目を指す。
17
- let(:rewritten) { described_class.rewrite(html) }
18
17
  subject(:result) { rewritten.first }
18
+
19
+ let(:rewritten) { described_class.rewrite(html) }
19
20
  let(:skipped) { rewritten.last }
20
21
 
21
22
  context '要素直下の単純なテキストノード' do
@@ -189,10 +190,11 @@ describe CopyTunerClient::Copyray::Rewriter do
189
190
  end
190
191
 
191
192
  it 'logger.warn で例外内容を記録する' do
192
- logger = double('logger')
193
+ logger = instance_double(Logger)
193
194
  allow(CopyTunerClient.configuration).to receive(:logger).and_return(logger)
194
- expect(logger).to receive(:warn).with(/Rewriter failed.*RuntimeError.*boom/)
195
+ allow(logger).to receive(:warn)
195
196
  result
197
+ expect(logger).to have_received(:warn).with(/Rewriter failed.*RuntimeError.*boom/)
196
198
  end
197
199
 
198
200
  it 'logger が nil でもフォールバックが落ちない' do
@@ -203,18 +205,21 @@ describe CopyTunerClient::Copyray::Rewriter do
203
205
  end
204
206
 
205
207
  context 'fragment: true(turbo stream などの HTML 断片)' do
206
- let(:html) { %(<turbo-stream action="replace" target="x"><template><p>#{marker('a.b')}Hello</p></template></turbo-stream>) }
207
- subject(:result) { described_class.rewrite(html, fragment: true).first }
208
+ subject(:fragment_result) { described_class.rewrite(html, fragment: true).first }
209
+
210
+ let(:html) do
211
+ %(<turbo-stream action="replace" target="x"><template><p>#{marker('a.b')}Hello</p></template></turbo-stream>)
212
+ end
208
213
 
209
214
  it 'html/body ラッパを足さず断片のまま返す' do
210
- expect(result).not_to include('<html>')
211
- expect(result).not_to include('<body>')
212
- expect(result).to start_with('<turbo-stream')
215
+ expect(fragment_result).not_to include('<html>')
216
+ expect(fragment_result).not_to include('<body>')
217
+ expect(fragment_result).to start_with('<turbo-stream')
213
218
  end
214
219
 
215
220
  it 'template 内の要素に data-copyray-key を付与しトークンを除去する' do
216
- expect(result).to include('data-copyray-key="a.b"')
217
- expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
221
+ expect(fragment_result).to include('data-copyray-key="a.b"')
222
+ expect(fragment_result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
218
223
  end
219
224
  end
220
225
 
@@ -8,12 +8,11 @@ describe CopyTunerClient::CopyrayMiddleware do
8
8
  CopyTunerClient::Copyray::Marker.encode(key)
9
9
  end
10
10
 
11
- let(:headers) { { 'Content-Type' => 'text/html' } }
11
+ subject(:middleware) { described_class.new(app) }
12
+
12
13
  let(:app) { ->(_env) { [status, headers, [body]] } }
13
14
  let(:status) { 200 }
14
15
 
15
- subject(:middleware) { described_class.new(app) }
16
-
17
16
  before do
18
17
  CopyTunerClient.configure do |configuration|
19
18
  configuration.project_id = 1
@@ -25,6 +24,7 @@ describe CopyTunerClient::CopyrayMiddleware do
25
24
  end
26
25
 
27
26
  context 'マーカートークンを含む HTML レスポンスのとき' do
27
+ let(:headers) { { 'Content-Type' => 'text/html' } }
28
28
  let(:body) { "<html><body><p>#{marker('a.b')}Hello</p></body></html>" }
29
29
 
30
30
  it 'マーカーを data-copyray-key 属性に書き換え、トークンを除去する' do
@@ -37,13 +37,16 @@ describe CopyTunerClient::CopyrayMiddleware do
37
37
 
38
38
  it '書き換え後のボディから Content-Length を再計算する' do
39
39
  _status, out_headers, response = middleware.call({})
40
- expect(out_headers['Content-Length']).to eq response.join.bytesize.to_s
40
+ expected_length = response.join.bytesize.to_s
41
+ expect(out_headers['Content-Length']).to eq expected_length
41
42
  end
42
43
  end
43
44
 
44
45
  context 'turbo stream レスポンスのとき' do
45
46
  let(:headers) { { 'Content-Type' => 'text/vnd.turbo-stream.html' } }
46
- let(:body) { %(<turbo-stream action="replace" target="x"><template><p>#{marker('a.b')}Hello</p></template></turbo-stream>) }
47
+ let(:body) do
48
+ %(<turbo-stream action="replace" target="x"><template><p>#{marker('a.b')}Hello</p></template></turbo-stream>)
49
+ end
47
50
 
48
51
  before do
49
52
  # NOTE: append_js のトップレベル no-op スタブを外し、turbo stream では JS を挿入しないことを検証する。
@@ -89,7 +92,7 @@ describe CopyTunerClient::CopyrayMiddleware do
89
92
  # NOTE: append_js は private かつ Rails の view ヘルパー(javascript_tag 等)に依存する。
90
93
  # トップレベルの no-op スタブを外して実体を呼び、ヘルパーは渡された script 本文をそのまま
91
94
  # 返す最小フェイクに差し替えて、window.CopyTuner に keysSkipped が埋まることだけ検証する。
92
- subject(:script) { middleware.__send__(:append_js, '<html><body></body></html>', nil, skipped: skipped) }
95
+ subject(:script) { middleware.__send__(:append_js, '<html><body></body></html>', nil, skipped:) }
93
96
 
94
97
  let(:fake_helpers) do
95
98
  Class.new {
@@ -3,7 +3,7 @@ require 'copy_tuner_client/copyray'
3
3
 
4
4
  describe CopyTunerClient::Copyray do
5
5
  describe '.augment_template' do
6
- subject { CopyTunerClient::Copyray.augment_template(source, key) }
6
+ subject(:augmented) { described_class.augment_template(source, key) }
7
7
 
8
8
  let(:key) { 'en.test.key' }
9
9
 
@@ -18,11 +18,11 @@ describe CopyTunerClient::Copyray do
18
18
  let(:source) { FakeHtmlSafeString.new('<b>Hello</b>').html_safe }
19
19
 
20
20
  it 'keeps the html_safe flag so the translation is not re-escaped' do
21
- is_expected.to be_html_safe
21
+ expect(augmented).to be_html_safe
22
22
  end
23
23
 
24
24
  it 'prepends the visible marker token without escaping' do
25
- is_expected.to eq '⟦CT:en.test.key⟧<b>Hello</b>'
25
+ expect(augmented).to eq '⟦CT:en.test.key⟧<b>Hello</b>'
26
26
  end
27
27
  end
28
28
 
@@ -30,22 +30,22 @@ describe CopyTunerClient::Copyray do
30
30
  let(:source) { FakeHtmlSafeString.new('Hello & <World>') }
31
31
 
32
32
  it 'prepends the marker but keeps the source non html_safe so ActionView still escapes the body' do
33
- is_expected.to eq '⟦CT:en.test.key⟧Hello & <World>'
34
- is_expected.not_to be_html_safe
33
+ expect(augmented).to eq '⟦CT:en.test.key⟧Hello & <World>'
34
+ expect(augmented).not_to be_html_safe
35
35
  end
36
36
  end
37
37
 
38
38
  context 'when the key matches local_first_key_regexp' do
39
- let(:key) { 'views.foo' }
39
+ let(:local_first_key) { 'views.foo' }
40
40
 
41
41
  before { CopyTunerClient.configuration.local_first_key_regexp = /\Aviews\./ }
42
42
 
43
43
  it 'does not inject the marker into a plain source' do
44
- expect(CopyTunerClient::Copyray.augment_template('Hello', key)).to eq 'Hello'
44
+ expect(described_class.augment_template('Hello', local_first_key)).to eq 'Hello'
45
45
  end
46
46
 
47
47
  it 'does not inject the marker into an html_safe source' do
48
- expect(CopyTunerClient::Copyray.augment_template('Hello'.html_safe, key)).to eq 'Hello'
48
+ expect(described_class.augment_template('Hello'.html_safe, local_first_key)).to eq 'Hello'
49
49
  end
50
50
  end
51
51
  end
@@ -1,8 +1,8 @@
1
1
  require 'spec_helper'
2
2
 
3
3
  describe CopyTunerClient::DottedHash do
4
- describe ".to_h" do
5
- subject { CopyTunerClient::DottedHash.to_h(dotted_hash) }
4
+ describe '.to_h' do
5
+ subject(:converted_hash) { described_class.to_h(dotted_hash) }
6
6
 
7
7
  context '空のキーの場合' do
8
8
  let(:dotted_hash) { {} }
@@ -22,7 +22,7 @@ describe CopyTunerClient::DottedHash do
22
22
  it { is_expected.to eq({ 'key' => 'test value', 'other_key' => 'other value' }) }
23
23
  end
24
24
 
25
- context "複数階層のblurbキーの場合" do
25
+ context '複数階層のblurbキーの場合' do
26
26
  let(:dotted_hash) do
27
27
  {
28
28
  'en.test.key' => 'en test value',
@@ -31,24 +31,24 @@ describe CopyTunerClient::DottedHash do
31
31
  }
32
32
  end
33
33
 
34
- it "正しくネストされたハッシュに変換されること" do
35
- is_expected.to eq({
36
- 'en' => {
37
- 'test' => {
38
- 'key' => 'en test value',
39
- 'other_key' => 'en other test value',
40
- },
41
- },
42
- 'fr' => {
43
- 'test' => {
44
- 'key' => 'fr test value',
45
- },
46
- },
47
- })
34
+ it '正しくネストされたハッシュに変換されること' do
35
+ expect(converted_hash).to eq({
36
+ 'en' => {
37
+ 'test' => {
38
+ 'key' => 'en test value',
39
+ 'other_key' => 'en other test value',
40
+ },
41
+ },
42
+ 'fr' => {
43
+ 'test' => {
44
+ 'key' => 'fr test value',
45
+ },
46
+ },
47
+ })
48
48
  end
49
49
  end
50
50
 
51
- context "キーの競合がある場合" do
51
+ context 'キーの競合がある場合' do
52
52
  let(:dotted_hash) do
53
53
  {
54
54
  'en.test' => 'invalid value',
@@ -62,7 +62,7 @@ describe CopyTunerClient::DottedHash do
62
62
  # NOTE: number.*.format 配下のキー(precision 等)は I18nBackend の local_first_key? ガードで
63
63
  # tree_cache をバイパスしローカル YAML 優先になるため、ここで型変換せず文字列のまま保持する。
64
64
  # (number_to_currency が壊れないことは i18n_backend_spec の number ローカル優先テストで担保)
65
- context "number.*.format 配下の値を含む場合" do
65
+ context 'number.*.format 配下の値を含む場合' do
66
66
  let(:dotted_hash) do
67
67
  {
68
68
  'en.number.currency.format.precision' => '2',
@@ -70,19 +70,19 @@ describe CopyTunerClient::DottedHash do
70
70
  }
71
71
  end
72
72
 
73
- it "型変換せず値を文字列のまま保持する" do
74
- is_expected.to eq({
75
- 'en' => {
76
- 'number' => { 'currency' => { 'format' => { 'precision' => '2' } } },
77
- 'custom' => { 'precision' => 'custom_value' },
78
- },
79
- })
73
+ it '型変換せず値を文字列のまま保持する' do
74
+ expect(converted_hash).to eq({
75
+ 'en' => {
76
+ 'number' => { 'currency' => { 'format' => { 'precision' => '2' } } },
77
+ 'custom' => { 'precision' => 'custom_value' },
78
+ },
79
+ })
80
80
  end
81
81
  end
82
82
  end
83
83
 
84
- describe ".conflict_keys" do
85
- subject { CopyTunerClient::DottedHash.conflict_keys(dotted_hash) }
84
+ describe '.conflict_keys' do
85
+ subject(:conflicts) { described_class.conflict_keys(dotted_hash) }
86
86
 
87
87
  context '有効なキーの場合' do
88
88
  let(:dotted_hash) do
@@ -106,11 +106,11 @@ describe CopyTunerClient::DottedHash do
106
106
  }
107
107
  end
108
108
 
109
- it "競合するキーが正しく検出されること" do
110
- is_expected.to eq({
111
- 'ja.fuga.test' => %w[ja.fuga.test.hoge],
112
- 'ja.hoge.test' => %w[ja.hoge.test.fuga ja.hoge.test.hoge],
113
- })
109
+ it '競合するキーが正しく検出されること' do
110
+ expect(conflicts).to eq({
111
+ 'ja.fuga.test' => %w[ja.fuga.test.hoge],
112
+ 'ja.hoge.test' => %w[ja.hoge.test.fuga ja.hoge.test.hoge],
113
+ })
114
114
  end
115
115
  end
116
116
  end
@@ -3,57 +3,59 @@ require 'copy_tuner_client/helper_extension'
3
3
  require 'copy_tuner_client/copyray'
4
4
 
5
5
  describe CopyTunerClient::HelperExtension do
6
- # NOTE: helper_extension が参照する CopyTunerClient::Rails は engine への依存があり
7
- # 単体 spec では require できないため、メソッドをスタブできる最小の入れ物だけ用意する。
8
- module CopyTunerClient
9
- module Rails
10
- def self.controller_of_rails_engine?(_controller)
11
- false
12
- end
13
- end
14
- end
6
+ include DefinesConstants
15
7
 
16
8
  # NOTE: request.format で描画フォーマットを判定するため、format を差し替えられる
17
9
  # 最小のフェイク request / controller を用意する。mailer 判定は controller の型で行う。
18
- Format = Struct.new(:type) do
19
- def html?
20
- type == :html
21
- end
10
+ def format_class
11
+ @format_class ||=
12
+ Struct.new(:type) do
13
+ def html?
14
+ type == :html
15
+ end
16
+ end
22
17
  end
23
- Request = Struct.new(:format)
24
- Controller = Struct.new(:request)
25
-
26
- module KeywordArgumentsHelper
27
- attr_writer :controller
28
-
29
- # NOTE: ActionView の TranslationHelper を模し、.html/_html キーのみ html_safe な訳文を返す。
30
- # マーカーは平文・html_safe どちらにも注入されるが、html_safe フラグの引き継ぎを検証できるよう両方返し分ける。
31
- def translate(key, **options)
32
- source = "Hello, #{options[:name]}"
33
- key.to_s.end_with?('.html', '_html') ? source.html_safe : source
34
- end
35
-
36
- def controller
37
- return @controller if defined?(@controller)
38
18
 
39
- # NOTE: 実 HTML 描画では controller が存在し request.format が html になるため、
40
- # デフォルトはそれを再現した controller。
41
- @controller = Controller.new(Request.new(Format.new(:html)))
42
- end
19
+ def request_class
20
+ @request_class ||= Struct.new(:format)
43
21
  end
44
22
 
45
- class KeywordArgumentsView
46
- include KeywordArgumentsHelper
23
+ def controller_class
24
+ @controller_class ||= Struct.new(:request)
47
25
  end
48
26
 
49
- CopyTunerClient::HelperExtension.hook_translation_helper(KeywordArgumentsHelper, middleware_enabled: true)
27
+ # NOTE: hook_translation_helper は渡されたモジュール自体を破壊的に変更する(alias_method 等)ため、
28
+ # view に include するモジュールと hook_translation_helper に渡すモジュールは同一オブジェクトである必要がある。
29
+ def keyword_arguments_helper
30
+ @keyword_arguments_helper ||=
31
+ Module.new do
32
+ attr_accessor :controller
33
+
34
+ # NOTE: ActionView の TranslationHelper を模し、.html/_html キーのみ html_safe な訳文を返す。
35
+ # マーカーは平文・html_safe どちらにも注入されるが、html_safe フラグの引き継ぎを検証できるよう両方返し分ける。
36
+ define_method(:translate) do |key, **options|
37
+ source = "Hello, #{options[:name]}"
38
+ key.to_s.end_with?('.html', '_html') ? source.html_safe : source
39
+ end
40
+ end
41
+ end
50
42
 
51
- let(:view) { KeywordArgumentsView.new }
43
+ # NOTE: HTML 描画では controller が存在し request.format が html になるため、
44
+ # デフォルトはそれを再現した controller を持たせておく。
45
+ let(:view) do
46
+ Class.new.include(keyword_arguments_helper).new.tap do |v|
47
+ v.controller = controller_class.new(request_class.new(format_class.new(:html)))
48
+ end
49
+ end
52
50
 
53
51
  before do
54
- # NOTE: controller_of_rails_engine? ::Rails::Engine への依存があり単体 spec では評価できないため、
55
- # この spec の関心(注入ガード)に絞って常に false を返すようスタブする。
56
- allow(CopyTunerClient::Rails).to receive(:controller_of_rails_engine?).and_return(false)
52
+ # NOTE: helper_extension が参照する CopyTunerClient::Rails engine への依存があり
53
+ # 単体 spec では require できず、controller_of_rails_engine? も ::Rails::Engine への
54
+ # 依存があり単体 spec では評価できないため、この spec の関心(注入ガード)に絞って
55
+ # 常に false を返すフェイクモジュールに差し替える。
56
+ fake_rails_module = Module.new { def self.controller_of_rails_engine?(_controller) = false }
57
+ define_constant('CopyTunerClient::Rails', fake_rails_module)
58
+ described_class.hook_translation_helper(keyword_arguments_helper, middleware_enabled: true)
57
59
  end
58
60
 
59
61
  it 'works with keyword argument method' do
@@ -82,7 +84,7 @@ describe CopyTunerClient::HelperExtension do
82
84
 
83
85
  %i[json text csv pdf].each do |format|
84
86
  it "does not inject the marker when request.format is :#{format}" do
85
- view.controller = Controller.new(Request.new(Format.new(format)))
87
+ view.controller = controller_class.new(request_class.new(format_class.new(format)))
86
88
  expect(view.translate('some.key', name: 'World')).to eq 'Hello, World'
87
89
  end
88
90
  end
@@ -101,13 +103,13 @@ describe CopyTunerClient::HelperExtension do
101
103
  end
102
104
 
103
105
  it 'does not inject the marker when the controller has no request' do
104
- view.controller = Controller.new(nil)
106
+ view.controller = controller_class.new(nil)
105
107
  expect(view.translate('some.key', name: 'World')).to eq 'Hello, World'
106
108
  end
107
109
 
108
110
  it 'does not raise when ActionMailer is not loaded' do
109
111
  hide_const('ActionMailer::Base') if defined?(ActionMailer::Base)
110
- view.controller = Controller.new(Request.new(Format.new(:html)))
112
+ view.controller = controller_class.new(request_class.new(format_class.new(:html)))
111
113
  expect { view.translate('some.key', name: 'World') }.not_to raise_error
112
114
  end
113
115
  end
@@ -116,9 +118,10 @@ describe CopyTunerClient::HelperExtension do
116
118
  # 維持されなければならない。注入ガードが初期値登録まで巻き添えで止めていないことを保証する。
117
119
  context 'default value registration' do
118
120
  it 'registers the default value even when the marker is not injected' do
119
- view.controller = Controller.new(Request.new(Format.new(:json)))
120
- expect(I18n).to receive(:t).with('some.key', hash_including(default: 'Default'))
121
+ view.controller = controller_class.new(request_class.new(format_class.new(:json)))
122
+ allow(I18n).to receive(:t)
121
123
  view.translate('some.key', name: 'World', default: 'Default')
124
+ expect(I18n).to have_received(:t).with('some.key', hash_including(default: 'Default'))
122
125
  end
123
126
  end
124
127
  end