diff --git a/.rubocop.yml b/.rubocop.yml index 578f546..d26f34f 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -33,6 +33,7 @@ Metrics/ParameterLists: CountKeywordArgs: false Metrics/ClassLength: + Max: 100 Exclude: # Test classes are mostly tables of cases. - test/**/* diff --git a/CHANGELOG.md b/CHANGELOG.md index f17ceac..45b8581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,76 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `config.validate_languages = false` globally. See [Languages](docs/languages.md). +- **Cache keys change for any document containing a `pre` or `code` + element.** `pre` and `code` are now opaque (see Added, below), so what + gets sent to the provider changed, and what gets keyed changed with it; + an entry cached under the old behaviour keeps serving what the old + behaviour produced. Give the configuration a new `cache_namespace`, or + let `cache_ttl` lapse, to get every such document retranslated. See + [Caching](docs/caching.md#what-a-cache-key-is-made-of). + +- **A runtime `cache_namespace` change now moves the rate limiter too.** + `cache_namespace` names the limiter's own bookkeeping namespace as well + as the cache store's; it used to move only the store, leaving the + limiter counting silently under the old namespace. `active_record_base` + behaves the same way and for the same reason -- the SQL-backed limiter + builds its model from that class just as the store does. See + [Configuration](docs/configuration.md#changing-configuration-at-runtime). + +- **A provider with a blank `cache_key` now raises + `TranslationDiff::InvalidProviderError`, not + `TranslationDiff::Translator::Error`.** The two are siblings under + `TranslationDiff::Error`, not parent and child, so an application + rescuing the old class specifically stops catching this failure. + Rescue `TranslationDiff::Error` to catch both. See + [Errors](docs/errors.md). + ### Added +- **Every event from one `translate` call now shares a `call_id`.** Generated + once per call, opaque, and never derived from the text, it lands in + `translate`, `cache`, `request`, `rate_limit`, `usage` and `cache_error` + alike. Before it, a subscriber receiving `cache` or `request` events had no + way to tell which `translate` call they belonged to, short of tagging + `Thread.current` itself -- a workaround that breaks the moment two + translations share a thread. See + [Instrumentation](docs/instrumentation.md). + +- **`translate` now carries `characters`: the total this call considered, + hit or miss.** A call served entirely from cache never fires a `request` + event and used to report nothing about its size; it now reports a number + there instead. `request`'s own `characters` keeps its narrower meaning -- + what one batch actually sent -- so the two fields share a name but not an + event: summing the wrong one produces a wrong bill. See + [Instrumentation](docs/instrumentation.md). + +- **`TranslationDiff.preview` predicts a `translate` call without making + it.** It answers how many sentences a call would send, how many the cache + already has, and how many characters that is -- without calling a + provider and without writing anything. A preview never pays for language + detection, so `from:` is required wherever there is anything to preview; + leaving it unset raises `TranslationDiff::Previewer::Error`. Built for an + editor that wants to show "this edit will send 1 sentence" before the + author saves. See + [Caching](docs/caching.md#asking-what-a-call-would-do-without-doing-it). + +- **`pre` and `code` are no longer sent for translation, and changing a + configuration option at runtime now rebuilds only what it actually + feeds.** `pre` and `code` join `script` and `style` in + `config.opaque_elements`, the set `TranslationDiff::Passage` never treats + as prose -- default `%i[script style pre code]`, widen or narrow it as + needed -- after a live `
` block came back from Google with
+  `jq '.meters'` mangled into `jq '.metros'`, because nothing told the
+  pipeline that code holds language, not prose. Separately, `provider` and
+  the cache, pool, rate and segmenter options each rebuild only their own
+  collaborator now, instead of nothing: before this, switching `provider`
+  at runtime meant `TranslationDiff.reset!` and reconfiguring from scratch,
+  discarding a Redis pool that had no reason to go. See
+  [Configuration](docs/configuration.md#changing-configuration-at-runtime).
+  Two upgrade consequences of this are filed under Breaking, above:
+  cache keys changing for a document containing `pre` or `code`, and a
+  runtime `cache_namespace` change now moving the rate limiter too.
+
 - A `usage` instrumentation event, firing once per provider request, beside
   `translate`, `cache`, `request` and `rate_limit`. Its payload carries
   `provider`, `characters` (what this library sent, counted locally),
@@ -143,6 +211,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
 
 ### Fixed
 
+- **A `notranslate` span nested inside an opaque element (`pre`, `code`,
+  `script` or `style`) no longer silences every sentence after it.**
+  Closing the protected span used to leave the scanner's own opacity depth
+  one too high, so nothing past it was ever handed to the segmenter again.
+  Found while adding the `pre`/`code` opaque elements above, and fixed the
+  same way for all four. See [How it works](docs/how-it-works.md#html).
 - **Google and DeepL translations in HTML mode no longer come back
   double-escaped.** Both vendors return entity-escaped text -- an
   apostrophe as `'`, a quote as `"`, an ampersand as `&` -- and
diff --git a/README.md b/README.md
index e469e7d..1a85048 100644
--- a/README.md
+++ b/README.md
@@ -67,6 +67,13 @@ end
 formal.translate(contract, from: "en", to: "de", formality: :more)
 ```
 
+```ruby
+# See what a call would send and find cached, without calling the provider or writing anything
+preview = TranslationDiff.preview(contract, from: "en", to: "de")
+preview.sendable_sentences # => sentences not yet cached
+preview.cached_sentences   # => sentences already cached
+```
+
 ```ruby
 # Redis-backed cache, shared across processes
 TranslationDiff.configure { |config| config.redis_url = ENV["REDIS_URL"] }
diff --git a/docs/caching.md b/docs/caching.md
index 5834f64..8b439c7 100644
--- a/docs/caching.md
+++ b/docs/caching.md
@@ -58,6 +58,48 @@ you nothing.
 No options at all contributes no field to the key, which is the four-field
 key every already-warm cache is keyed on.
 
+## Asking what a call would do, without doing it
+
+`TranslationDiff.preview` answers what a `translate` call would send and
+find cached, without calling a provider and without writing anything: how
+many sentences it would send, how many the cache already has, and how many
+characters that is. It reads the same store, through the same
+`SentenceCache`, keyed the same way -- see [What a cache key is made
+of](#what-a-cache-key-is-made-of) above -- so a preview and the call it
+predicts always agree.
+
+```ruby
+preview = TranslationDiff.preview(article_body, from: "en", to: "es")
+preview.sendable_sentences   # => 1, not yet cached
+preview.cached_sentences     # => 4, already cached
+preview.sendable_characters  # => 23
+preview.characters           # => 412, the total this call would consider
+```
+
+`sendable_sentences` and `cached_sentences` are the same two counts the
+`cache` event reports as `misses` and `hits`; `characters` is the same total
+the `translate` event reports. A preview and the call it predicts are
+answering the same question through the same numbers, so "this edit will
+send 23 of 412 characters" and what the events for that call later report
+should agree.
+
+**`from:` is required wherever there is anything to preview.** `translate`
+can leave `from:` unset and pay for one `#detect` request to find it; a
+preview never calls the provider, so it cannot pay for that request either.
+Passing `to:` alone raises `TranslationDiff::Previewer::Error`, naming the
+provider and telling you to pass `from:` explicitly -- unless the document
+holds nothing translatable, or the source and target already match, in
+which case there is nothing to preview and an empty result comes back
+regardless of `from:`.
+
+This is the supported way to ask an editor's question before it becomes a
+bill -- show "this edit will send 1 sentence" before the author saves:
+
+```ruby
+preview = TranslationDiff.preview(edited_body, from: "en", to: "es")
+"This edit will send #{preview.sendable_sentences} sentence#{'s' unless preview.sendable_sentences == 1}."
+```
+
 ## The cache store contract
 
 `config.cache` accepts either a registered name (`:redis`, `:memory`,
diff --git a/docs/configuration.md b/docs/configuration.md
index bca53cf..50db04f 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -69,6 +69,7 @@ at all, so an unset environment variable never has to be special-cased.
 | `rate_interval` | `60` | Seconds over which `rate_limit` (or a limiter's own default threshold) is measured. **Actually enforced over roughly 5-600 seconds** -- see [The rate limiter contract](contracts.md#the-rate-limiter-contract). |
 | `rate_limiter` | `nil` | A registered name (`:redis`, `:active_record`) or an object satisfying the [rate limiter contract](contracts.md#the-rate-limiter-contract). `nil` with `rate_limit` also `nil` means no rate limiting; `nil` with `rate_limit` set resolves to `:redis`. Setting `rate_limiter` alone -- with `rate_limit` left unset -- is enough to turn rate limiting on, at the limiter's own default threshold; it no longer needs `rate_limit` set to avoid crashing. |
 | `segmenter` | `:pragmatic` | The sentence segmenter: a registered name or an object satisfying the [segmenter contract](contracts.md#the-segmenter-contract). |
+| `opaque_elements` | `%i[script style pre code]` | Element names `TranslationDiff::Passage` never treats as prose, whatever they contain, matched case-insensitively so `
` and `")
   end
 
+  # pre and code join script and style: their text is left alone, however much of it looks like prose.
+  def test_pre_and_code_contents_are_not_prose
+    source = %(

See:

curl -s https://example.com/level | jq '.meters'

after

) + + assert_equal ["See:", "after"], cores(source) + assert_round_trips(source) + end + + # Ox hands back element names exactly as written -- :PRE, :Pre, :STYLE -- never lowercased, so an opaque set + # compared case-sensitively misses every one of these and the markup still reaches the provider. + def test_uppercase_pre_and_code_contents_are_not_prose + source = %(

See:

curl -s https://example.com/level | jq '.meters'

after

) + + assert_equal ["See:", "after"], cores(source) + assert_round_trips(source) + end + + def test_mixed_case_pre_and_code_contents_are_not_prose + source = "

See:

keep me

after

" + + assert_equal ["See:", "after"], cores(source) + assert_round_trips(source) + end + + def test_uppercase_script_and_style_contents_are_not_prose + assert_equal %w[аль бра кил], cores("альбракил") + end + + def test_mixed_case_script_and_style_contents_are_not_prose + assert_equal %w[аль бра кил], cores("альбракил") + end + + # The common case, not the block one: a code span mid-sentence must not split the sentence around it or eat a space. + def test_an_inline_code_span_leaves_the_sentence_around_it_intact + source = "Press Ctrl+C to stop." + + assert_equal ["Press", "to stop."], cores(source) + assert_equal "PRESS Ctrl+C TO STOP.", translated(source) + end + + def test_an_empty_code_element_round_trips + assert_round_trips("Before.After.") + end + + def test_a_code_element_holding_only_whitespace_round_trips + assert_round_trips("Before. After.") + end + + def test_a_pre_holding_markup_looking_text_round_trips + assert_round_trips("
<div> not real markup
After.") + end + + # A notranslate span nested inside an opaque element used to leak: closing it left @opaque_depth one too high, + # so every sentence after the code block silently stopped being sent. This is the regression test for that. + def test_a_notranslate_span_inside_a_code_block_does_not_confuse_the_walker + source = %(DO_NOT_TOUCH After this all good.) + + assert_equal [%(DO_NOT_TOUCH), "After this all good."], cores(source) + assert_equal %(DO_NOT_TOUCH AFTER THIS ALL GOOD.), translated(source) + end + + # Protection beats opacity on purpose, same as it does for script: a code span inside notranslate stays one unit. + def test_a_code_span_inside_a_notranslate_element_stays_inside_the_protected_unit + source = %(x After.) + + assert_equal [source], cores(source) + end + + # The set is configurable: an application can widen it, or shrink it back to script and style. + def test_opaque_elements_is_configurable_per_passage + source = "Before.keep meAfter." + subject = TranslationDiff::Passage.new(source, segmenter: TranslationDiff::Segmenters::Pragmatic.new, + opaque_elements: %i[script style]) + + assert_equal ["Before.", "keep me", "After."], subject.segments.reject(&:empty?).map(&:core) + end + def test_a_comment_is_not_prose assert_equal ["Visible text here."], cores(" Visible text here.") end @@ -73,7 +157,7 @@ def test_a_notranslate_span_reaches_the_provider_with_its_tags assert_equal [%(Bold Mountain is a good place.)], cores(source) end - # Ox lowercases element names but not attribute names, and HTML attribute names are case-insensitive. + # Ox does not lowercase attribute names either, and HTML attribute names are case-insensitive. def test_an_uppercase_class_attribute_still_protects source = %(Bold Mountain is a good place.) diff --git a/test/translation_diff/previewer_test.rb b/test/translation_diff/previewer_test.rb new file mode 100644 index 0000000..c43fa2d --- /dev/null +++ b/test/translation_diff/previewer_test.rb @@ -0,0 +1,223 @@ +require "test_helper" + +class PreviewerTest < ConfiguredTest + class RecordingProvider < TranslationDiff::Provider + def self.capabilities + TranslationDiff::Capabilities.new( + max_request_size: 1_000, max_batch_size: 10, max_text_size: nil, + html: :none, notranslate: false, detects_language: true, reports_billing: false + ) + end + + attr_reader :requests + + def initialize(config) + super + @requests = [] + end + + def translate(request) + @requests << request + TranslationDiff::Translation::Response.build(request: request, texts: request.texts.map(&:upcase)) + end + + def detect(_text) = "en" + def cache_key = "recording" + end + + # The capability is the only honest test: every provider inherits a #detect that raises. + class BlindProvider < RecordingProvider + def self.capabilities + TranslationDiff::Capabilities.new( + max_request_size: 1_000, max_batch_size: 10, max_text_size: nil, + html: :none, notranslate: false, detects_language: false, reports_billing: false + ) + end + + def cache_key = "blind" + end + + # An empty cache key would file this provider's translations in every other provider's namespace. + class NamelessProvider < RecordingProvider + def cache_key = " " + end + + # Counts every write the pipeline attempts, whichever contract it uses, so "writes nothing" has real evidence. + class WriteTrackingStore + attr_reader :write_calls + + def initialize + @inner = TranslationDiff::MemoryCacheStore.new(max_size: 100) + @write_calls = 0 + end + + def read_multi(keys) = @inner.read_multi(keys) + + def write_multi(pairs) + @write_calls += 1 + @inner.write_multi(pairs) + end + end + + class Recorder + attr_reader :events + + def initialize = @events = [] + + def instrument(name, payload) + @events << [name, payload] + yield if block_given? + end + end + + def setup + super + @provider = RecordingProvider.new(TranslationDiff::Configuration.new) + TranslationDiff.configure { |c| c.cache = :memory } + end + + def preview(values, **) + TranslationDiff.preview(values, provider: @provider, **) + end + + def test_a_document_nothing_has_cached_counts_every_sentence_as_sendable + result = preview("One. Two.", from: "en", to: "ru") + + assert_equal 2, result.sendable_sentences + assert_equal 0, result.cached_sentences + assert_equal "One.Two.".size, result.sendable_characters + assert_equal "One.Two.".size, result.characters + end + + # The denominator a fully-cached call still needs: sendable_characters alone would report 0 of nothing, + # leaving no way to say "this call would send 0 of 8 characters" rather than "there was nothing to send". + def test_after_translating_nothing_is_left_to_send + TranslationDiff.translate("One. Two.", from: "en", to: "ru", provider: @provider) + + result = preview("One. Two.", from: "en", to: "ru") + + assert_equal 0, result.sendable_sentences + assert_equal 2, result.cached_sentences + assert_equal 0, result.sendable_characters + assert_equal "One.Two.".size, result.characters + end + + # This is the property the whole thing exists for: an edit to one sentence sends exactly that sentence. + def test_editing_one_sentence_sends_exactly_that_sentence + TranslationDiff.translate("One. Two.", from: "en", to: "ru", provider: @provider) + + result = preview("One. Three.", from: "en", to: "ru") + + assert_equal 1, result.sendable_sentences + assert_equal 1, result.cached_sentences + assert_equal "Three.".size, result.sendable_characters + end + + # The strongest test available: it pins the preview to the pipeline, not to an idea of the pipeline. + def test_the_previews_counts_equal_what_translate_then_reports_through_the_cache_event + TranslationDiff.translate("One. Two.", from: "en", to: "ru", provider: @provider) + predicted = preview("One. Three.", from: "en", to: "ru") + + recorder = Recorder.new + TranslationDiff.configure { |c| c.instrumenter = recorder } + TranslationDiff.translate("One. Three.", from: "en", to: "ru", provider: @provider) + payload = recorder.events.find { |name, _| name == "cache.translation_diff" }.last + + assert_equal predicted.sendable_sentences, payload[:misses] + assert_equal predicted.cached_sentences, payload[:hits] + end + + # The strongest available check that a preview's total and a translate call's own report of its size agree. + def test_the_previews_total_characters_equals_what_the_translate_event_reports + predicted = preview("One. Two.", from: "en", to: "ru") + + recorder = Recorder.new + TranslationDiff.configure { |c| c.instrumenter = recorder } + TranslationDiff.translate("One. Two.", from: "en", to: "ru", provider: @provider) + payload = recorder.events.find { |name, _| name == "translate.translation_diff" }.last + + assert_equal predicted.characters, payload[:characters] + end + + def test_an_explicit_provider_and_the_configured_one_agree + TranslationDiff.configure { |c| c.provider = @provider } + + from_config = TranslationDiff.preview("One. Two.", from: "en", to: "ru") + explicit = TranslationDiff.preview("One. Two.", from: "en", to: "ru", provider: @provider) + + assert_equal from_config, explicit + end + + def test_nothing_is_written + tracker = WriteTrackingStore.new + TranslationDiff.configure { |c| c.cache = tracker } + TranslationDiff.translate("One. Two.", from: "en", to: "ru", provider: @provider) + writes_after_translate = tracker.write_calls + + preview("One. Two.", from: "en", to: "ru") + preview("One. Three.", from: "en", to: "ru") + + assert_equal writes_after_translate, tracker.write_calls + end + + def test_the_same_language_needs_no_provider_and_sends_nothing + result = TranslationDiff.preview("One.", from: "en", to: "en", provider: Object.new) + + assert_equal 0, result.sendable_sentences + end + + def test_a_value_with_nothing_to_preview_resolves_no_provider + result = TranslationDiff.preview("", from: "en", to: "ru", provider: Object.new) + + assert_equal 0, result.sendable_sentences + end + + # Detection is a paid request; a preview never makes one, so it says so instead of guessing. + def test_a_missing_source_language_is_refused_when_the_provider_would_have_to_detect_it + error = assert_raises(TranslationDiff::Previewer::Error) { preview("One.", to: "ru") } + + assert_match(/paid request/, error.message) + assert_match(/from:/, error.message) + end + + def test_a_provider_that_cannot_detect_says_so_before_it_is_called + @provider = BlindProvider.new(TranslationDiff::Configuration.new) + + error = assert_raises(TranslationDiff::Previewer::Error) { preview("One.", to: "ru") } + + assert_match(/cannot detect/, error.message) + end + + def test_a_missing_target_language_is_refused_by_name + error = assert_raises(ArgumentError) { preview("One.", from: "en") } + + assert_match(/to:/, error.message) + end + + def test_per_call_options_change_the_cache_key_the_same_way_translate_does + TranslationDiff.translate("One.", from: "en", to: "ru", provider: @provider, formality: :less) + + default_options = preview("One.", from: "en", to: "ru") + same_options = preview("One.", from: "en", to: "ru", formality: :less) + + assert_equal 1, default_options.sendable_sentences + assert_equal 0, same_options.sendable_sentences + end + + # Same guard Translator uses, shared through TranslationDiff::Providers.resolve. + def test_a_provider_whose_cache_key_is_blank_is_refused_rather_than_lying_about_the_cache + @provider = NamelessProvider.new(TranslationDiff::Configuration.new) + + error = assert_raises(TranslationDiff::InvalidProviderError) { preview("One.", from: "en", to: "ru") } + + assert_match(/must define #cache_key/, error.message) + end + + def test_no_preview_error_message_carries_the_text_being_previewed + secret = "Zaphod Beeblebrox is president." + + error = assert_raises(TranslationDiff::Previewer::Error) { preview(secret, to: "ru") } + + refute_includes error.message, secret + end +end diff --git a/test/translation_diff/providers_test.rb b/test/translation_diff/providers_test.rb index 64e2c27..549136b 100644 --- a/test/translation_diff/providers_test.rb +++ b/test/translation_diff/providers_test.rb @@ -76,6 +76,36 @@ def test_null_keeps_its_own_cache_key assert_equal "null", TranslationDiff::Providers.build(:null, @config).cache_key end + # The one seam Translator and Previewer both resolve a provider through. + def test_resolve_with_nil_returns_the_configured_provider + @config.provider = :acme + + assert_instance_of AcmeProvider, TranslationDiff::Providers.resolve(nil, @config) + end + + def test_resolve_with_a_name_builds_that_provider + assert_instance_of AcmeProvider, TranslationDiff::Providers.resolve(:acme, @config) + end + + def test_resolve_with_an_object_uses_it_as_is + instance = TranslationDiff::Providers.build(:acme, @config) + + assert_same instance, TranslationDiff::Providers.resolve(instance, @config) + end + + # A blank cache_key would file a provider's translations in every other provider's namespace. + class BlankCacheKeyProvider < TranslationDiff::Provider + def cache_key = " " + end + + def test_resolve_refuses_a_provider_whose_cache_key_is_blank + instance = BlankCacheKeyProvider.new(@config) + + error = assert_raises(TranslationDiff::InvalidProviderError) { TranslationDiff::Providers.resolve(instance, @config) } + + assert_match(/must define #cache_key/, error.message) + end + # A defensive double `require` and a Rails reload both re-run registration; redeclaring must stay silent. def test_registering_the_same_provider_twice_is_not_a_conflict TranslationDiff::Providers.register(:acme, AcmeProvider) diff --git a/test/translation_diff/redis_rate_limiter_test.rb b/test/translation_diff/redis_rate_limiter_test.rb index 56acc7c..34ed58f 100644 --- a/test/translation_diff/redis_rate_limiter_test.rb +++ b/test/translation_diff/redis_rate_limiter_test.rb @@ -150,6 +150,20 @@ def test_build_falls_back_to_the_default_threshold_when_rate_limit_is_unset built.check(1) end + # The settings screen that found this: changing cache_namespace must move the limiter, not just the cache. + def test_changing_the_cache_namespace_moves_the_limiter_to_the_new_redis_namespace + server = FakeRedisServer.new + config = TranslationDiff::Configuration.new + config.rate_limit = 100 + config.instance_variable_set(:@redis_pool, FakeConnectionPool.new(server)) + + config.rate_limiter_instance.check(10) + config.cache_namespace = "tenant-42" + config.rate_limiter_instance.check(7) + + assert_equal({ "ratelimit:translation-diff:call" => 10, "ratelimit:tenant-42:call" => 7 }, server.totals) + end + private def limiter(server, **) diff --git a/test/translation_diff/translator_test.rb b/test/translation_diff/translator_test.rb index 3c5a445..1c17a84 100644 --- a/test/translation_diff/translator_test.rb +++ b/test/translation_diff/translator_test.rb @@ -153,10 +153,11 @@ def test_a_provider_that_cannot_detect_says_so_before_it_is_called assert_empty @provider.requests end + # Provider resolution, and this guard with it, now lives in TranslationDiff::Providers. def test_a_provider_whose_cache_key_is_blank_is_refused_rather_than_sharing_a_namespace @provider = NamelessProvider.new(TranslationDiff::Configuration.new) - error = assert_raises(TranslationDiff::Translator::Error) { translate("one.", from: "en", to: "ru") } + error = assert_raises(TranslationDiff::InvalidProviderError) { translate("one.", from: "en", to: "ru") } assert_match(/must define #cache_key/, error.message) end @@ -201,6 +202,27 @@ def test_the_translate_event_carries_languages_provider_and_a_count assert_equal 2, payload[:values] end + # `request`'s own `characters` is only what one batch sent; this is what the call considered, hit or miss. + def test_the_translate_event_carries_the_characters_this_call_considered + recorder = instrumented + instrumented_translate("Hello there.") + + assert_equal "Hello there.".size, payload_for(recorder, "translate")[:characters] + end + + # A call served entirely from cache still knows what it considered, even though no request event ever fires. + def test_the_translate_event_reports_characters_even_when_the_call_is_served_entirely_from_cache + recorder = instrumented + instrumented_translate("Hello there.") + instrumented_translate("Hello there.") + + payload = recorder.events.reverse.find { |event| event.first == "translate.translation_diff" }.last + request_count = recorder.events.count { |event| event.first == "request.translation_diff" } + + assert_equal "Hello there.".size, payload[:characters] + assert_equal 1, request_count + end + def test_the_cache_event_carries_hit_and_miss_counts recorder = instrumented instrumented_translate("Hello there.") @@ -212,6 +234,17 @@ def test_the_cache_event_carries_hit_and_miss_counts assert_equal "recording", payload[:provider] end + # One `Translator`, one identifier, in every event that call emits -- a subscriber's only way to group them. + def test_every_event_from_one_call_carries_the_same_call_id + recorder = instrumented { |c| c.rate_limiter = FakeRateLimiter.new } + instrumented_translate("Hello there.") + + call_ids = recorder.events.map { |_, payload| payload[:call_id] } + + refute_nil call_ids.first + assert_equal [call_ids.first] * call_ids.size, call_ids + end + # The cache is an optimisation: a translation already paid for at the provider must reach the caller regardless. def test_a_failing_cache_write_does_not_lose_a_translation_already_paid_for TranslationDiff.configure { |c| c.cache = FailingCacheStore.new } @@ -229,6 +262,13 @@ def test_a_failing_cache_write_emits_a_cache_error_event_naming_the_provider_and assert_equal "TranslatorTest::FailingCacheStore::BoomError", payload[:error] end + def test_a_failing_cache_write_events_call_id_matches_the_translate_events + recorder = instrumented { |c| c.cache = FailingCacheStore.new } + instrumented_translate("Hello there.") + + assert_equal payload_for(recorder, "translate")[:call_id], payload_for(recorder, "cache_error")[:call_id] + end + # The instrumentation payload carries the error's class, never the store's own message, which could quote the row. def test_a_failing_cache_writes_event_never_carries_the_text_being_translated secret = "Zaphod Beeblebrox is president."