diff --git a/CHANGELOG.md b/CHANGELOG.md
index c15d19e..53d7ff4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -106,15 +106,53 @@ described below. Everything here is relative to `deepl_diff` 2.2.0.
configuration still setting `deepl_host` raises `NoMethodError` on
`TranslationDiff.configure`. Rename it.
- A provider returning the wrong number of translations now raises
- `TranslationDiff::ResponseError`, not `TranslationDiff::Request::Error`.
- `Request::Error` still exists, and still means "`from:` is missing and the
- provider cannot detect"; a `rescue TranslationDiff::Request::Error` written
- to catch a short response no longer catches one. Both are
- `TranslationDiff::Error`, so a rescue of the base class is unaffected.
+ `TranslationDiff::ResponseError`, not the error that used to live on
+ `Request`; a `rescue` written to catch a short response that way no longer
+ catches one. Both are `TranslationDiff::Error`, so a rescue of the base
+ class is unaffected.
- A provider returning a well-formed response that carries no translation for
one input -- Azure answers 200 for a batch where a single string failed --
also raises `TranslationDiff::ResponseError`, naming the position. It
- previously reached `Spacing.restore` and died there as `NoMethodError`.
+ previously reached the spacing step and died there as `NoMethodError`.
+- **The translation pipeline is new code.** `TranslationDiff::Linearizer`,
+ `Spacing`, `Chunker`, `Tokenizer`, `Cache` and `Request` are gone as public
+ constants. What replaces them: `Document` and `Leaves` (walking the
+ caller's structure), `Passage`, `Fragment` and `Segment` (markup and prose,
+ cut into sentences), `Markup` (entity references and a `<` that opens no
+ tag), `Batch` (packing sentences into provider requests), `SentenceCache`
+ (the cache key, read and write) and `Translator` (the coordinator
+ `TranslationDiff.translate` and `Context#translate` now build). See
+ [How it works](docs/how-it-works.md). If you referenced any of the six by
+ name, that reference is now a `NameError`.
+- `TranslationDiff::Request::Error` is now `TranslationDiff::Translator::Error`
+ and `TranslationDiff::Cache::Error` is now
+ `TranslationDiff::SentenceCache::Error`. There is no alias for either: this
+ gem has never been published under the name `translation_diff` with those
+ constants in it. `TranslationDiff::Chunker::Error` is gone with no
+ replacement -- a single sentence too large to send now raises
+ `TranslationDiff::Batch::Error`. All three remain
+ `TranslationDiff::Error`, so a rescue of the base class is unaffected.
+- **`TranslationDiff.translate` and `Context#translate` raise `ArgumentError`
+ when `to:` is missing or `nil`.** The keyword still defaults to `nil` in the
+ signature, and the message names it. Previously a `nil` target compared
+ equal to a `nil` source, the call short-circuited as "same language" and
+ your values came back untranslated, silently. If you have a caller reading
+ `to:` out of a configuration that can be blank, it has been a no-op and will
+ now raise.
+- **The `cache` instrumentation event fires once per `translate` call, not
+ once per chunk.** The cache is now consulted for every sentence in one
+ `read_multi` before anything is batched. `hits` and `misses` still sum to
+ the same totals over a call, so a counter that adds them up is unaffected;
+ a counter of *events*, or a histogram of per-chunk hit ratios, will see the
+ cardinality drop. `request` and `rate_limit` still fire once per batch sent.
+- **Two more cache keys move, beyond the entity and `<` fixes below.** A
+ sentence padded with Unicode whitespace -- a non-breaking space, say -- now
+ keys as the bare sentence: the pipeline uses one Unicode-aware definition
+ of padding everywhere, where the key used to be built with ASCII `strip`,
+ which leaves a `U+00A0` in place. And the whole document key format is
+ otherwise unmoved: it is pinned by test against recorded values, and every
+ other input in the corpus this rewrite was judged against produces the same
+ key it did before.
### Removed
@@ -297,6 +335,29 @@ described below. Everything here is relative to `deepl_diff` 2.2.0.
- DeepL's batch limit was declared as 300 sentences per request; DeepL
documents 50. The request-size limit (1,700 escaped characters) was
already correct and is unchanged.
+- **An entity reference no longer reaches the provider raw.** `Salt &
+ pepper.` was sent to the provider as the six characters `&`, so the
+ provider translated the entity's spelling as if it were words -- and was
+ billed for it. It is now sent as `Salt & pepper.`, the text the document
+ actually says, and re-encoded on the way out. Named entities, `&` and
+ `&` alike are decoded; anything neither decoder knows is left as it
+ arrived.
+- **A bare `<` no longer swallows the rest of the sentence.** `if a < b then
+ stop. Fine.` was parsed by `ox` as prose followed by an unclosed tag, so
+ only `if a` was ever sent for translation and everything after the `<` came
+ back untranslated. A `<` that no element name, closing name, declaration or
+ instruction follows is now escaped before parsing and restored after, so the
+ whole sentence is translated. `5 < 6 and 7 > 6. True.` was sent as three
+ fragments and is now sent as two sentences.
+- **These two fixes move the cache key for the documents they affect.** A
+ document containing an entity reference, or a `<` that opens no tag, will
+ miss the cache once and be re-translated. That is the point: what was cached
+ for it was translated from the wrong text.
+- **A known remaining limit: `<` still reaches a provider undecoded**, and
+ `a ` and
+ `"
+CACHE_KEYS: ["null:en:ru:34430bf88522e4c1c49479eb2feeb3f3", "null:en:ru:3c11ac7405538ffcaad48db9d7149347", "null:en:ru:b1d36c7c01d8359faddba68b671e7d47"]
+
+=== processing instruction ===
+OUTPUT: "Hey!
Look!"
+CACHE_KEYS: ["null:en:ru:6ae99d4d2de5e3cbd29fec87ae7d76eb", "null:en:ru:8bd5669756914fb1c9a11cbd755dcd31"]
+
+=== comment ===
+OUTPUT: " Visible text here."
+CACHE_KEYS: ["null:en:ru:d41646cf35fd5a9a6959f6715d5e14bc"]
+
+=== doctype ===
+OUTPUT: "
Body text.
"
+CACHE_KEYS: ["null:en:ru:ac9d6317208a722c1c19bd667c374d22"]
+
+=== cdata ===
+OUTPUT: "Before.After."
+CACHE_KEYS: ["null:en:ru:97f4ef09e0c768ce8bf87f7b64a3faad", "null:en:ru:d5639cb2ca1fb06574bd02a8435be8ad"]
+
+=== notranslate span ===
+OUTPUT: "Bold Mountain is a good place."
+CACHE_KEYS: ["null:en:ru:68980e62d2581d949019300826e1b197"]
+
+=== nested notranslate ===
+OUTPUT: "foobarbaz"
+CACHE_KEYS: ["null:en:ru:eafcec3766a26334358d8df31c5a82cc"]
+
+=== notranslate inside span ===
+OUTPUT: "foobar
baz"
+CACHE_KEYS: ["null:en:ru:5b513613cd49691d47e6df65921f5264"]
+
+=== br before closing tag ===
+OUTPUT: "Смеркалось.
"
+CACHE_KEYS: ["null:en:ru:e1583f8217a1cfc2b7ee21afd908e955"]
+
+=== blank line between sentences ===
+OUTPUT: "Первое предложение.\n\nВторое предложение."
+CACHE_KEYS: ["null:en:ru:b83f03a6405a6e2d24f9d89380c661d4", "null:en:ru:f1af66af7c0dc420d4267696c7d0aa13"]
+
+=== single newline ===
+OUTPUT: "test\nphrase"
+CACHE_KEYS: ["null:en:ru:fae8d0840a5c100965609a9d7a55135e"]
+
+=== leading and trailing space ===
+OUTPUT: " Padded sentence. "
+CACHE_KEYS: ["null:en:ru:fe3bf43723a64fb32bcac8d99bb431af"]
+
+=== many sentences ===
+OUTPUT: "Sentence number 1. Sentence number 2. Sentence number 3. Sentence number 4. Sentence number 5. Sentence number 6. Sentence number 7. Sentence number 8. Sentence number 9. Sentence number 10. Sentence number 11. Sentence number 12. Sentence number 13. Sentence number 14. Sentence number 15. Sentence number 16. Sentence number 17. Sentence number 18. Sentence number 19. Sentence number 20. Sentence number 21. Sentence number 22. Sentence number 23. Sentence number 24. Sentence number 25. Sentence number 26. Sentence number 27. Sentence number 28. Sentence number 29. Sentence number 30. Sentence number 31. Sentence number 32. Sentence number 33. Sentence number 34. Sentence number 35. Sentence number 36. Sentence number 37. Sentence number 38. Sentence number 39. Sentence number 40."
+CACHE_KEYS: ["null:en:ru:028166de0c432e30aa01d79966d36e27", "null:en:ru:042f7dfcdaf51c2812e9975436ff7163", "null:en:ru:0b1fa3a903f7429246b94933e1c65a22", "null:en:ru:13b8656fe18559c2e98fff3aa3b25136", "null:en:ru:1c3a311a4eef441e056310213a1051e2", "null:en:ru:27c36e35e03a99369e2b60cee64758df", "null:en:ru:2fde1f57bcf20df4087f574ae00d9692", "null:en:ru:42c04e1e1ea3177d2921386c91d0c579", "null:en:ru:43d0f7cdc6be74da0e4cfd4ef8e9b886", "null:en:ru:4c94cb29ddbbbc231ac855011456f46d", "null:en:ru:4d00adc400cec01a812aed2a741778ef", "null:en:ru:522df2f9bbac92f9714bbbb2aa229bc7", "null:en:ru:5b885db48f0a06635ab079fd96c3c7e6", "null:en:ru:5c6d3d369e65d4e33708b48263f7aa16", "null:en:ru:62992cce554f47436aa2ebc09bfa4389", "null:en:ru:80c4df8aced58f66cc564ec992836c09", "null:en:ru:817375736915088db4c098d047232820", "null:en:ru:81c38a3ec70d697ec7c10480b40a82d2", "null:en:ru:831d768717f5c58649e33141ba610e22", "null:en:ru:881e7f42d84eeac1ee76c4db213304a3", "null:en:ru:a298c866d524ed0e6a6f81eea80eb3c6", "null:en:ru:a677508b1eadd12106cc9f3f604d7d51", "null:en:ru:a98a84ab5702ea2f29c01ac35ebd4a2e", "null:en:ru:aa0e7ef164f4b6a60049265162d7b1eb", "null:en:ru:b0dc3760c308f5db9454ff53afed6842", "null:en:ru:bb716ddca5d51bae619894f6fc092337", "null:en:ru:bbd3042479feacc0e179bc05eca8a762", "null:en:ru:be90cce4c0800e090bb1127e102aaa72", "null:en:ru:bfc9abd6522e8eb83e1c05d2a84f5b67", "null:en:ru:c41c34620d095e2db18519e409a58dcf", "null:en:ru:c6a1605fb42cad1439b88d5e1a62ea97", "null:en:ru:c8a3cab86135767607d8b6cff57f7e26", "null:en:ru:d49160d08ac9cb12ca8b1c2373085b12", "null:en:ru:daf84cfee6e6a35e13e9e994850e39e3", "null:en:ru:e299181f4980628d4b3ef62ad0042c4c", "null:en:ru:e6560e25669e4af82fc667a67b74b001", "null:en:ru:e9acf5c038149e3f8e03f6c62c317c89", "null:en:ru:eba0b3330f77dbe64b70db689be5e046", "null:en:ru:fb7c729c821d5b4d5ebdab2e5273b869", "null:en:ru:fe9379e1a22ff0052f72ef22dbaf1d96"]
+
+=== non-ascii ===
+OUTPUT: "Привет. Как дела? Всё хорошо."
+CACHE_KEYS: ["null:en:ru:3ae1adb068af8b1ca435825a5f6a2dd9", "null:en:ru:6440c78297d465869ba8f86b114f7328", "null:en:ru:cc34260931ed7b37f90ec7dc5b44f3ad"]
+
+=== entity ampersand ===
+OUTPUT: "Salt & pepper. Fine."
+CACHE_KEYS: ["null:en:ru:05d12994070fdde458e566149f42472f", "null:en:ru:b5010567e209726a125c9ed59162eca5"]
+
+=== entity nbsp ===
+OUTPUT: "Hard space here. Fine."
+CACHE_KEYS: ["null:en:ru:98abf3f0bf3b826e8f2c21fd72823687", "null:en:ru:b5010567e209726a125c9ed59162eca5"]
+
+=== bare less-than ===
+OUTPUT: "if a < b then stop. Fine."
+CACHE_KEYS: ["null:en:ru:56b4445b422a53efdff59cb72a58f599"]
+
+=== bare less-than and greater ===
+OUTPUT: "5 < 6 and 7 > 6. True."
+CACHE_KEYS: ["null:en:ru:4363b12ae39947f045a4fb5fad740dc8", "null:en:ru:65364203e430439161a97b3d245a0168", "null:en:ru:e4da3b7fbbce2345d7772b0674a318d5"]
+
diff --git a/test/support/pipeline_corpus.rb b/test/support/pipeline_corpus.rb
new file mode 100644
index 0000000..2da7416
--- /dev/null
+++ b/test/support/pipeline_corpus.rb
@@ -0,0 +1,48 @@
+# The inputs the pipeline rewrite is judged against. Every one of these is a
+# case some earlier bug or review turned up; none is invented.
+module PipelineCorpus
+ INPUTS = {
+ "plain sentence" => "Hello there.",
+ "two sentences" => "Hello there. Second sentence!",
+ "nested hash" => { title: "One. Two.", body: "Third." },
+ "nested array" => ["A. B.", ["C."], "D."],
+ "hash with non-strings" => { title: "One.", count: 42, missing: nil, flag: true },
+ "empty string" => "",
+ "nil" => nil,
+ "not a string" => 42,
+ "bold markup" => "Bold text here. Second sentence.",
+ "attributes preserved" => %(Link text. After.),
+ "void element" => "One line.
Two lines.",
+ "unclosed paragraph" => "First para.
Second para.",
+ "uppercase tags" => "Bold text.",
+ "script and style" => "альбракил",
+ "processing instruction" => %(Hey!
Look!),
+ "comment" => " Visible text here.",
+ "doctype" => "
Body text.
",
+ "cdata" => "Before.After.",
+ "notranslate span" => %(Bold Mountain is a good place.),
+ "nested notranslate" => "foobarbaz",
+ "notranslate inside span" => "foobar
baz",
+ "br before closing tag" => "Смеркалось.
",
+ "blank line between sentences" => "Первое предложение.\n\nВторое предложение.",
+ "single newline" => "test\nphrase",
+ "leading and trailing space" => " Padded sentence. ",
+ "many sentences" => (1..40).map { |i| "Sentence number #{i}." }.join(" "),
+ "non-ascii" => "Привет. Как дела? Всё хорошо.",
+ "entity ampersand" => "Salt & pepper. Fine.",
+ "entity nbsp" => "Hard space here. Fine.",
+ "bare less-than" => "if a < b then stop. Fine.",
+ "bare less-than and greater" => "5 < 6 and 7 > 6. True.",
+ # A recorded limit, not a defect: `Bold" without a lexer of our own.
+ "bare less-than before a letter" => "a [
- %w[a b c],
- [%w[a b c]]
- ],
- "splits_on_the_size_limit" => [
- [LONG] * 3,
- [[LONG, LONG], [LONG]]
- ],
- "splits_between_values_of_uneven_size" => [
- ([MEDIUM] * 3) + [LONG],
- [[MEDIUM, MEDIUM], [MEDIUM, LONG]]
- ],
- "splits_on_the_count_limit" => [
- [SHORT] * 10,
- [[SHORT] * 5, [SHORT] * 5]
- ]
- }.freeze
-
- CASES.each do |name, (values, expected)|
- define_method(:"test_#{name}") do
- assert_equal expected, chunk(values)
- end
- end
-
- def test_raises_when_a_single_value_exceeds_the_limit
- error = assert_raises(TranslationDiff::Chunker::Error) { chunk([OVERSIZED]) }
-
- assert_match(/Too long part/, error.message)
- end
-
- # CGI.escape inflates Cyrillic sixfold; measuring raw String#size let chunks of non-ASCII text run over.
- def test_measures_non_ascii_values_by_their_escaped_size
- value = "я" * 3
-
- # Measured raw, both values fit in one chunk of 20; measured as sent, they cannot.
- assert_equal 3, value.size
- assert_equal 18, CGI.escape(value).size
- assert_equal [[value], [value]], chunk([value, value])
- end
-
- def test_raises_when_the_escaped_size_of_one_value_exceeds_the_limit
- error = assert_raises(TranslationDiff::Chunker::Error) { chunk(["я" * 4]) }
-
- assert_match(/Too long part 24 > 20/, error.message)
- end
-
- private
-
- def chunk(values)
- TranslationDiff::Chunker.new(values, limit: LIMIT, count_limit: COUNT_LIMIT).call
- end
-end
diff --git a/test/translation_diff/context_test.rb b/test/translation_diff/context_test.rb
index 9f7c062..d2a4f49 100644
--- a/test/translation_diff/context_test.rb
+++ b/test/translation_diff/context_test.rb
@@ -45,4 +45,12 @@ def test_a_context_translates_through_its_own_configuration
assert_equal "Hello.", context.translate("Hello.", from: "en", to: "ru")
end
+
+ # `to:` still defaults to nil here too, so a context refuses a missing target by naming the keyword.
+ def test_a_missing_target_language_is_refused_by_name
+ context = TranslationDiff.context { |c| c.cache_namespace = "tenant" }
+ error = assert_raises(ArgumentError) { context.translate("Hello.", from: "en") }
+
+ assert_match(/to:/, error.message)
+ end
end
diff --git a/test/translation_diff/document_test.rb b/test/translation_diff/document_test.rb
new file mode 100644
index 0000000..e66f925
--- /dev/null
+++ b/test/translation_diff/document_test.rb
@@ -0,0 +1,76 @@
+require "test_helper"
+
+class DocumentTest < Minitest::Test
+ def map(value, &) = TranslationDiff::Document.new(value).map(&)
+
+ def test_a_bare_string_is_mapped
+ assert_equal "HELLO", map("hello", &:upcase)
+ end
+
+ def test_a_hash_keeps_its_keys_and_their_order
+ result = map({ title: "one", body: "two" }, &:upcase)
+
+ assert_equal({ title: "ONE", body: "TWO" }, result)
+ assert_equal %i[title body], result.keys
+ end
+
+ def test_an_array_keeps_its_order
+ assert_equal %w[A B C], map(%w[a b c], &:upcase)
+ end
+
+ def test_nesting_of_both_kinds_survives
+ value = { a: ["one", { b: "two" }], c: "three" }
+
+ assert_equal({ a: ["ONE", { b: "TWO" }], c: "THREE" }, map(value, &:upcase))
+ end
+
+ # Anything that is not a String is not translatable and must arrive on the
+ # other side as the same object, in the same place.
+ def test_non_strings_pass_through_untouched
+ value = { text: "one", count: 42, missing: nil, flag: true, at: :symbol }
+
+ assert_equal({ text: "ONE", count: 42, missing: nil, flag: true, at: :symbol },
+ map(value, &:upcase))
+ end
+
+ def test_the_block_is_not_called_for_non_strings
+ seen = []
+ map({ text: "one", count: 42, missing: nil }) do |s|
+ seen << s
+ s
+ end
+
+ assert_equal ["one"], seen
+ end
+
+ def test_an_empty_string_is_still_a_string
+ assert_equal [""], TranslationDiff::Document.new([""]).strings
+ end
+
+ def test_strings_are_returned_in_document_order
+ value = { a: ["one", { b: "two" }], c: "three" }
+
+ assert_equal %w[one two three], TranslationDiff::Document.new(value).strings
+ end
+
+ def test_strings_does_not_modify_the_value
+ value = { a: ["one"] }
+ TranslationDiff::Document.new(value).strings
+
+ assert_equal({ a: ["one"] }, value)
+ end
+
+ # The caller's structure is theirs. Mapping returns a new one.
+ def test_map_does_not_mutate_the_original
+ value = { a: ["one"] }
+ map(value, &:upcase)
+
+ assert_equal({ a: ["one"] }, value)
+ end
+
+ def test_deep_nesting_does_not_lose_its_shape
+ value = [[[["deep"]]]]
+
+ assert_equal [[[["DEEP"]]]], map(value, &:upcase)
+ end
+end
diff --git a/test/translation_diff/fragment_test.rb b/test/translation_diff/fragment_test.rb
new file mode 100644
index 0000000..19bf34e
--- /dev/null
+++ b/test/translation_diff/fragment_test.rb
@@ -0,0 +1,12 @@
+require "test_helper"
+
+class FragmentTest < Minitest::Test
+ # Rendering used to hand back the slice itself, so appending to it edited the document it came from.
+ def test_rendering_markup_does_not_hand_out_the_fragment_source
+ fragment = TranslationDiff::Fragment.markup("")
+
+ fragment.render << "XXX"
+
+ assert_equal "", fragment.render
+ end
+end
diff --git a/test/translation_diff/leaves_test.rb b/test/translation_diff/leaves_test.rb
new file mode 100644
index 0000000..13f8f73
--- /dev/null
+++ b/test/translation_diff/leaves_test.rb
@@ -0,0 +1,39 @@
+require "test_helper"
+
+class LeavesTest < Minitest::Test
+ def collapse(value) = TranslationDiff::Leaves.collapse_nils(value)
+
+ def count(value) = TranslationDiff::Leaves.count(value)
+
+ def test_a_nested_nil_becomes_an_empty_string
+ assert_equal({ a: "one", skip: "" }, collapse({ a: "one", skip: nil }))
+ assert_equal ["one", "", 42], collapse(["one", nil, 42])
+ end
+
+ def test_a_bare_nil_becomes_an_empty_string_too
+ assert_equal "", collapse(nil)
+ end
+
+ def test_every_other_leaf_is_handed_back_as_it_is
+ assert_equal({ a: "one", n: 42, f: false }, collapse({ a: "one", n: 42, f: false }))
+ end
+
+ def test_the_caller_s_own_structure_is_never_modified
+ value = { a: "one", nested: ["two", nil] }
+ collapse(value)
+
+ assert_equal({ a: "one", nested: ["two", nil] }, value)
+ end
+
+ def test_counting_covers_every_leaf_whatever_its_type
+ assert_equal 3, count({ a: "one", n: 42, skip: nil })
+ assert_equal 5, count({ a: "one", b: { c: "two", d: [1, "three", nil] } })
+ end
+
+ def test_a_scalar_is_one_leaf_and_an_empty_container_is_none
+ assert_equal 1, count("one")
+ assert_equal 1, count(nil)
+ assert_equal 0, count([])
+ assert_equal 0, count({})
+ end
+end
diff --git a/test/translation_diff/linearizer_test.rb b/test/translation_diff/linearizer_test.rb
deleted file mode 100644
index be37589..0000000
--- a/test/translation_diff/linearizer_test.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-require "test_helper"
-
-class LinearizerTest < Minitest::Test
- CASES = {
- "a_single_value" => "Value",
- "an_array" => [1, :two, "Three"],
- "a_nested_hash" => { a: "1", b: 2, c: { d: :three } }
- }.freeze
-
- CASES.each do |name, value|
- define_method(:"test_round_trips_#{name}") do
- linearized = TranslationDiff::Linearizer.linearize(value)
-
- assert_equal value, TranslationDiff::Linearizer.restore(value, linearized)
- end
- end
-end
diff --git a/test/translation_diff/markup_test.rb b/test/translation_diff/markup_test.rb
new file mode 100644
index 0000000..400f408
--- /dev/null
+++ b/test/translation_diff/markup_test.rb
@@ -0,0 +1,194 @@
+require "test_helper"
+
+class MarkupTest < Minitest::Test
+ def passage(source)
+ TranslationDiff::Passage.new(source, segmenter: TranslationDiff::Segmenters::Pragmatic.new)
+ end
+
+ def cores(source) = passage(source).segments.reject(&:empty?).map(&:core)
+
+ def translated(source)
+ subject = passage(source)
+ subject.segments.reject(&:empty?).each { |s| s.translation = s.core.upcase }
+ subject.render
+ end
+
+ # What the :null provider does: every sentence comes back as the text it was sent, so only markup handling shows.
+ def echoed(source)
+ subject = passage(source)
+ subject.segments.reject(&:empty?).each { |s| s.translation = s.core }
+ subject.render
+ end
+
+ def assert_round_trips(source)
+ assert_equal source, passage(source).render, "render must return the source byte for byte"
+ end
+
+ # -- entities ------------------------------------------------------------
+
+ # What reaches a provider is text, so it gets the character, not the entity.
+ def test_an_entity_is_decoded_before_translation
+ assert_equal ["Salt & pepper.", "Fine."], cores("Salt & pepper. Fine.")
+ end
+
+ def test_a_non_breaking_space_is_decoded
+ assert_equal ["Hard\u00A0space here.", "Fine."], cores("Hard space here. Fine.")
+ end
+
+ # The document keeps the entities it arrived with.
+ def test_entities_are_restored_on_render
+ assert_equal "SALT & PEPPER. FINE.", translated("Salt & pepper. Fine.")
+ end
+
+ def test_an_untranslated_document_with_entities_round_trips
+ source = "Salt & pepper. Hard space. Fine."
+
+ assert_equal source, passage(source).render
+ end
+
+ # -- the bare < ----------------------------------------------------------
+
+ def test_a_bare_less_than_stays_in_the_sentence
+ assert_equal ["if a < b then stop.", "Fine."], cores("if a < b then stop. Fine.")
+ end
+
+ def test_bare_angles_on_both_sides_stay_prose
+ assert_equal ["5 < 6 and 7 > 6.", "True."], cores("5 < 6 and 7 > 6. True.")
+ end
+
+ def test_a_bare_less_than_survives_rendering_untranslated
+ source = "if a < b then stop. Fine."
+
+ assert_equal source, passage(source).render
+ end
+
+ # The distinction that makes this hard: a real tag must still be a tag when
+ # the same string also contains a bare <.
+ def test_a_real_tag_beside_a_bare_less_than_is_still_markup
+ assert_equal ["if a < b then", "stop."], cores("if a < b then stop.")
+ assert_equal "IF A < B THEN STOP.", translated("if a < b then stop.")
+ end
+
+ def test_a_less_than_immediately_before_a_letter_is_a_tag
+ assert_equal ["Bold", "text."], cores("Bold text.")
+ end
+
+ # -- the escape and its inverse ------------------------------------------
+
+ # Every mixture of real tags, bare angles and already-escaped angles the
+ # workaround has to survive; each must come back exactly as it went in.
+ MIXED = [
+ "if a < b then stop.",
+ "5 < 6 and 7 > 6.",
+ "Bold and a < b.",
+ "a < b and c < d.",
+ "< is already an entity.",
+ "< is an entity for an entity.",
+ "a < b, < c, < d, e.",
+ " Visible.",
+ %(Link. After.),
+ "trailing angle <",
+ "<",
+ "<3 is not a tag.",
+ ""
+ ].freeze
+
+ def test_restoring_undoes_escaping
+ MIXED.each do |source|
+ escaped = TranslationDiff::Markup.escape_bare_angles(source)
+
+ assert_equal source, TranslationDiff::Markup.restore_bare_angles(escaped),
+ "escape and restore must be a matched pair for #{source.inspect}"
+ end
+ end
+
+ def test_escaping_leaves_no_bare_angle_for_ox_to_swallow
+ MIXED.each do |source|
+ escaped = TranslationDiff::Markup.escape_bare_angles(source)
+
+ refute_match(%r{<(?![A-Za-z!?]|/[A-Za-z])}, escaped, "#{escaped.inspect} still holds a bare <")
+ end
+ end
+
+ def test_every_mixed_input_round_trips_through_a_passage
+ MIXED.each { |source| assert_round_trips(source) }
+ end
+
+ # -- decoding and encoding -----------------------------------------------
+
+ # Every entity is decoded, not the two that were measured: an `&` we leave behind is an `&` encoding corrupts.
+ def test_decoding_resolves_named_and_numeric_entities
+ assert_equal "\u00A9 \u2014 \u2026", TranslationDiff::Markup.decode_entities("© — …")
+ assert_equal "& < > \" ' \u00A0", TranslationDiff::Markup.decode_entities("& < > " ' ")
+ assert_equal "& & \u00A0 \u00A0", TranslationDiff::Markup.decode_entities("& & ")
+ end
+
+ # Sane rather than an exception, and sane here means untouched: what is not an entity is text, and stays text.
+ def test_decoding_leaves_a_malformed_entity_exactly_as_it_arrived
+ ["¬anentity;", "ZZ;", "", "", "&Bogus9;", "AT&T", "a > b", "&"].each do |text|
+ assert_equal text, TranslationDiff::Markup.decode_entities(text)
+ end
+ end
+
+ # A numeric reference can name a surrogate; decoding one would hand back invalid UTF-8 for a later regexp to raise on.
+ def test_decoding_refuses_a_reference_that_would_not_be_valid_utf8
+ decoded = TranslationDiff::Markup.decode_entities("")
+
+ assert_equal "", decoded
+ assert_predicate decoded, :valid_encoding?
+ end
+
+ # Only the two characters that are unsafe in HTML text; a decoded character stays the character it decoded to.
+ def test_encoding_touches_only_the_ampersand_and_the_opening_angle
+ assert_equal "& <b> > \" ' \u00A0", TranslationDiff::Markup.encode_entities("& > \" ' \u00A0")
+ end
+
+ # -- entities we never decoded -------------------------------------------
+
+ # Every one of these came back with its `&` escaped a second time before the decode was made whole.
+ def test_an_entity_outside_the_decoded_set_is_not_escaped_again
+ assert_round_trips("A > B here. Fine.")
+ assert_round_trips("AT&T is a company. Fine.")
+ assert_round_trips("© 2026. Fine.")
+ assert_round_trips("¬anentity; here. Fine.")
+ assert_round_trips("One — two … three. Fine.")
+ end
+
+ # The bargain, in a test: bytes are promised only while a segment is untranslated.
+ def test_a_translated_sentence_renders_equivalent_markup_rather_than_equal_bytes
+ assert_equal "A > B here. Fine.", echoed("A > B here. Fine.")
+ assert_equal "AT&T is a company. Fine.", echoed("AT&T is a company. Fine.")
+ assert_equal "Hard\u00A0space here. Fine.", echoed("Hard space here. Fine.")
+ end
+
+ # The whole HTML5 named set, not just the specials CGI knows: a spelled-out `©` would display as text, not as ©.
+ def test_a_named_entity_outside_cgis_table_is_translated_as_the_character_it_means
+ assert_equal ["\u00A9 2026.", "Fine."], cores("© 2026. Fine.")
+ assert_equal "\u00A9 2026. Fine.", echoed("© 2026. Fine.")
+ assert_equal "One \u2014 two \u2026 three. Fine.", echoed("One — two … three. Fine.")
+ end
+
+ # A lone `&` is invalid XML and Ox raises on it, so nothing but a single well-formed entity is ever handed over.
+ def test_a_lone_ampersand_never_reaches_the_entity_resolver
+ assert_equal ["AT&T is a company.", "Fine."], cores("AT&T is a company. Fine.")
+ assert_equal "AT&T is a company. Fine.", echoed("AT&T is a company. Fine.")
+ assert_equal "R&D; x", TranslationDiff::Markup.decode_entities("R&D; x")
+ end
+
+ # -- what still has to hold ----------------------------------------------
+
+ # A notranslate element is prose that contains real tags; encoding must not eat them.
+ def test_a_notranslate_element_keeps_its_tags_through_a_render
+ assert_round_trips(%(Bold Mountain is a good place.))
+ end
+
+ # Passed through untouched means untouched: an `&` inside a protected element is not ours to respell.
+ def test_a_notranslate_element_keeps_its_ampersands_through_a_render
+ assert_round_trips(%(R&D & more Fine.))
+ end
+
+ def test_an_entity_inside_markup_is_left_for_the_browser
+ assert_round_trips(%(Link text. After.))
+ assert_round_trips("Before.After.")
+ end
+end
diff --git a/test/translation_diff/passage_test.rb b/test/translation_diff/passage_test.rb
new file mode 100644
index 0000000..808f062
--- /dev/null
+++ b/test/translation_diff/passage_test.rb
@@ -0,0 +1,152 @@
+require "test_helper"
+
+class PassageTest < Minitest::Test
+ def passage(source)
+ TranslationDiff::Passage.new(source, segmenter: TranslationDiff::Segmenters::Pragmatic.new)
+ end
+
+ # What a provider would be asked to translate, in order.
+ def cores(source) = passage(source).segments.reject(&:empty?).map(&:core)
+
+ def assert_round_trips(source)
+ assert_equal source, passage(source).render, "render must return the source byte for byte"
+ end
+
+ # -- byte-exact reconstruction ------------------------------------------
+
+ # rubocop:disable-next Metrics/MethodLength
+ def test_every_corpus_input_round_trips_untranslated
+ [
+ "Hello there.",
+ "Bold text here. Second sentence.",
+ %(Link text. After.),
+ "One line.
Two lines.",
+ "First para.
Second para.",
+ "Bold text.",
+ "альбракил",
+ %(Hey!
Look!),
+ " Visible text here.",
+ "
Body text.
",
+ "Before.After.",
+ "Смеркалось.
",
+ " Padded sentence. ",
+ "Первое предложение.\n\nВторое предложение.",
+ ""
+ ].each { |source| assert_round_trips(source) }
+ end
+
+ # -- what counts as prose ----------------------------------------------
+
+ def test_text_around_markup_is_prose
+ assert_equal ["Bold", "text here.", "Second sentence."],
+ cores("Bold text here. Second sentence.")
+ end
+
+ def test_script_and_style_contents_are_not_prose
+ assert_equal %w[аль бра кил], cores("альбракил")
+ end
+
+ def test_a_comment_is_not_prose
+ assert_equal ["Visible text here."], cores(" Visible text here.")
+ end
+
+ def test_a_doctype_is_not_prose
+ assert_equal ["Body text."], cores("Body text.
")
+ end
+
+ def test_a_processing_instruction_is_not_prose
+ assert_equal %w[Hey! Look!], cores(%(Hey!
Look!))
+ end
+
+ def test_attribute_values_are_not_prose
+ assert_equal ["Link text.", "After."], cores(%(Link text. After.))
+ end
+
+ # -- notranslate, which is prose ON PURPOSE ------------------------------
+
+ # The provider honours class="notranslate" itself, under the HTML mode all
+ # six providers send. Holding the span back as markup would deprive it of
+ # the protection it exists to request.
+ def test_a_notranslate_span_reaches_the_provider_with_its_tags
+ source = %(Bold Mountain is a good place.)
+
+ 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.
+ def test_an_uppercase_class_attribute_still_protects
+ source = %(Bold Mountain is a good place.)
+
+ assert_equal [source], cores(source)
+ end
+
+ # Class token values are case-sensitive in HTML and providers look for the lowercase word, so this asks for nothing.
+ def test_an_uppercase_notranslate_value_does_not_protect
+ assert_equal ["Bold Mountain", "is a good place."],
+ cores(%(Bold Mountain is a good place.))
+ end
+
+ def test_a_notranslate_span_nested_in_another_is_one_unit
+ source = "foobarbaz"
+
+ assert_equal [source], cores(source)
+ end
+
+ def test_a_notranslate_span_inside_an_ordinary_span_keeps_the_outer_span_as_markup
+ source = "foobar
baz"
+
+ assert_equal ["foobar
baz"], cores(source)
+ end
+
+ # Protection beats opacity: the caller asked for this subtree to be passed through, script and all.
+ def test_a_script_inside_a_notranslate_element_stays_inside_the_protected_unit
+ source = %()
+
+ assert_equal [source], cores(source)
+ end
+
+ # -- sentence boundaries -------------------------------------------------
+
+ def test_prose_is_cut_into_sentences
+ assert_equal ["! Киловольт.", "Смеркалось.", "Ворчало.", "Кричало."],
+ cores("! Киловольт. Смеркалось. Ворчало. Кричало.")
+ end
+
+ def test_a_blank_line_separates_sentences
+ assert_equal ["Первое предложение.", "Второе предложение."],
+ cores("Первое предложение.\n\nВторое предложение.")
+ end
+
+ # A lone newline is not a sentence boundary -- this was a real regression.
+ def test_a_single_newline_does_not_split_a_sentence
+ assert_equal ["test\nphrase"], cores("test\nphrase")
+ end
+
+ def test_markup_holding_nothing_but_a_line_break_offers_nothing_to_translate
+ assert_empty cores("\n
")
+ assert_round_trips("\n
")
+ end
+
+ # -- translation and rendering -------------------------------------------
+
+ def test_translating_every_segment_rebuilds_the_document
+ subject = passage("Bold text here. Second sentence.")
+ subject.segments.reject(&:empty?).each { |s| s.translation = s.core.upcase }
+
+ assert_equal "BOLD TEXT HERE. SECOND SENTENCE.", subject.render
+ end
+
+ def test_padding_between_sentences_survives_translation
+ subject = passage(" One. Two. ")
+ subject.segments.reject(&:empty?).each { |s| s.translation = s.core.upcase }
+
+ assert_equal " ONE. TWO. ", subject.render
+ end
+
+ def test_an_untranslated_segment_renders_its_source
+ subject = passage("One. Two.")
+ subject.segments.reject(&:empty?).first.translation = "ОДИН."
+
+ assert_equal "ОДИН. Two.", subject.render
+ end
+end
diff --git a/test/translation_diff/pipeline_corpus_test.rb b/test/translation_diff/pipeline_corpus_test.rb
new file mode 100644
index 0000000..01b083c
--- /dev/null
+++ b/test/translation_diff/pipeline_corpus_test.rb
@@ -0,0 +1,94 @@
+require "test_helper"
+require "support/pipeline_corpus"
+
+# Judges the pipeline rewrite against test/fixtures/pipeline_baseline.txt, captured before any of the pipeline
+# changed, translating every input the way the baseline script did: through the :null provider, en to ru.
+class PipelineCorpusTest < ConfiguredTest
+ # Committed, not read from a developer's home directory: a test that depends on an untracked file on one machine
+ # passes there and fails everywhere else, which is what it did in CI.
+ BASELINE_PATH = File.expand_path("../fixtures/pipeline_baseline.txt", __dir__)
+
+ def self.baseline_outputs
+ @baseline_outputs ||= File.read(BASELINE_PATH).scan(/^=== (.+) ===\nOUTPUT: (.*)\n/).to_h
+ end
+
+ def translated(name)
+ TranslationDiff.translate(PipelineCorpus::INPUTS.fetch(name), from: "en", to: "ru", provider: :null).inspect
+ end
+
+ def passage(name)
+ TranslationDiff::Passage.new(PipelineCorpus::INPUTS.fetch(name),
+ segmenter: TranslationDiff::Segmenters::Pragmatic.new)
+ end
+
+ # What Passage now hands a provider, which is where the fixed inputs actually differ.
+ def provider_texts(name) = passage(name).segments.reject(&:empty?).map(&:core)
+
+ # Nothing translated, so the document owes its caller the bytes it arrived as.
+ def untranslated_render(name) = passage(name).render
+
+ # Every sentence back as it was sent, which is what :null does -- the new pipeline's answer to the document column.
+ def echoed_render(name)
+ subject = passage(name)
+ subject.segments.reject(&:empty?).each { |segment| segment.translation = segment.core }
+ subject.render
+ end
+
+ def self.method_name_for(name) = :"test_#{name.gsub(/[^a-zA-Z0-9]+/, '_')}"
+
+ (PipelineCorpus::INPUTS.keys - PipelineCorpus::EXPECTED_TO_CHANGE).each do |name|
+ define_method(method_name_for(name)) do
+ assert_equal self.class.baseline_outputs.fetch(name), translated(name)
+ end
+ end
+
+ # The names in EXPECTED_TO_CHANGE, written out: the texts a provider is sent, what TranslationDiff.translate
+ # returns end to end, and Passage's render round trip -- byte-exact untranslated, equivalent markup once every
+ # sentence is back. document: and echoed: are independent literals, kept apart even where they agree, so a
+ # regression in either translate or render is caught by its own assertion rather than by one value checked twice.
+ # The pipeline this replaced sent ["Salt & pepper.", "Fine."] for the first, ["Hard space here.", "Fine."]
+ # for the second, ["if a"] for the third, ["5", "6.", "True."] for the fourth and ["a"] for the fifth.
+ CHANGED = {
+ "entity ampersand" => {
+ texts: ["Salt & pepper.", "Fine."],
+ document: "Salt & pepper. Fine.",
+ echoed: "Salt & pepper. Fine."
+ },
+ "entity nbsp" => {
+ texts: ["Hard\u00A0space here.", "Fine."],
+ # The entity is spelled as the character it means, which is the same document to a browser and not the same bytes.
+ document: "Hard\u00A0space here. Fine.",
+ echoed: "Hard\u00A0space here. Fine."
+ },
+ "bare less-than" => {
+ texts: ["if a < b then stop.", "Fine."],
+ document: "if a < b then stop. Fine.",
+ echoed: "if a < b then stop. Fine."
+ },
+ "bare less-than and greater" => {
+ texts: ["5 < 6 and 7 > 6.", "True."],
+ document: "5 < 6 and 7 > 6. True.",
+ echoed: "5 < 6 and 7 > 6. True."
+ },
+ # The recorded limit: ` {
+ texts: ["a"],
+ document: "a Black",
- color: %(So Red\n that)
- }
- }.freeze
-
- MARKUP_TRANSLATED = {
- title: "Один",
- more: {
- description: "Черный",
- color: %(Что Кра\n что)
- }
- }.freeze
-
- def test_translates_text_around_markup_and_leaves_the_markup_alone
- result, api = translate(MARKUP_VALUES, %w[Один Черный Что Кра что])
-
- assert_equal MARKUP_TRANSLATED, result
- assert_equal [[%w[One Black So Red that], :en, :ru, {}]], api.calls
- end
-
- # True of the public interface, but not new: 2.1.0 already protected the caller's hash by dup-ing it.
- def test_repeated_calls_leave_the_callers_options_hash_alone
- options = { from: :en, to: :ru }
-
- configure_with(FakeApi.new(["Какая-то строка", "Какая-то строка"]))
-
- 2.times do
- assert_equal "Какая-то строка", TranslationDiff.translate("Some string", **options)
- end
- assert_equal({ from: :en, to: :ru }, options)
- end
-
- # The initializer now declares one positional parameter, so a positional options hash is no longer accepted.
- def test_the_positional_options_hash_is_no_longer_accepted
- assert_raises(ArgumentError) do
- TranslationDiff::Request.new("text", { from: :en, to: :ru })
- end
- end
-
- # A detected language is a String while :to is usually a Symbol -- without casecmp? this never short-circuits.
- def test_skips_the_translation_when_the_detected_language_is_the_target
- api = FakeApi.new([], detected: "RU")
- configure_with(api)
-
- result = TranslationDiff::Request.new("привет", to: :ru).call
-
- assert_equal "привет", result
- assert_equal [[:detect, "привет"]], api.calls
- end
-
- # Chunker's limits used to come from two provider methods; they now come from the declared capabilities.
- # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength
- def test_chunking_uses_the_providers_declared_capabilities
- narrow = Class.new(TranslationDiff::Provider) do
- def self.capabilities
- TranslationDiff::Capabilities.new(
- max_request_size: 20, max_batch_size: 1, max_text_size: nil,
- html: :none, notranslate: false, detects_language: false, reports_billing: false
- )
- end
-
- attr_reader :batches
-
- def initialize(config)
- super
- @batches = []
- end
-
- def translate(request)
- @batches << request.texts
- TranslationDiff::Translation::Response.build(request: request, texts: request.texts)
- end
-
- def cache_key = "narrow"
- end
-
- provider = narrow.new(TranslationDiff::Configuration.new)
- TranslationDiff.translate("One. Two. Three.", from: "en", to: "ru", provider: provider)
-
- assert(provider.batches.all? { |batch| batch.size == 1 },
- "expected one text per request, got #{provider.batches.inspect}")
- end
-
- # The old check was `respond_to?(:detect)`, satisfied by inheriting the base class's raising stub.
- def test_a_provider_that_cannot_detect_says_so_before_it_is_called
- error = assert_raises(TranslationDiff::Request::Error) do
- TranslationDiff.translate("Some text.", to: "ru", provider: :null)
- end
-
- assert_match(/cannot detect/, error.message)
- assert_match(/null/, error.message)
- end
-
- # The count check now lives in Translation::Response.build, so it holds for every provider, hence ResponseError.
- def test_raises_when_the_api_returns_fewer_translations_than_asked_for
- configure_with(FakeApi.new(%w[Один]))
-
- error = assert_raises(TranslationDiff::ResponseError) do
- TranslationDiff::Request.new({ a: "One", b: "Two" }, from: :en, to: :ru).call
- end
-
- assert_match(/returned 1 translations for 2 values/, error.message)
- end
-
- # These used to raise NoMethodError on #empty? or TypeError inside Ox.
- UNTRANSLATABLE = [42, :sym, "", " "].freeze
-
- UNTRANSLATABLE.each do |value|
- define_method(:"test_passes_through_#{value.inspect.gsub(/\W/, '_')}_untouched") do
- api = FakeApi.new([])
- configure_with(api)
-
- assert_equal value, TranslationDiff::Request.new(value, from: :en, to: :ru).call
- assert_empty api.calls
- end
- end
-
- def test_passes_through_nil_untouched
- api = FakeApi.new([])
- configure_with(api)
-
- assert_nil TranslationDiff::Request.new(nil, from: :en, to: :ru).call
- assert_empty api.calls
- end
-
- # Scalars nested in a structure are passed through too, while nil keeps collapsing to "".
- def test_passes_nested_scalars_through_and_still_blanks_out_nils
- configure_with(FakeApi.new(%w[Один]))
-
- result = TranslationDiff::Request.new({ a: "One", n: 42, skip: nil }, from: :en, to: :ru).call
-
- assert_equal({ a: "Один", n: 42, skip: "" }, result)
- end
-
- # Proves the generalisation took effect: a provider declaring tiny limits must change the batching.
- def test_batches_according_to_the_limits_the_adapter_declares
- api = NarrowBatchApi.new(%w[Один Два])
- configure_with(api)
-
- TranslationDiff::Request.new({ a: "One", b: "Two" }, from: :en, to: :ru).call
-
- assert_equal 2, api.calls.size, "one call per text at a batch size of 1"
- end
-
- # The only Request-level test exercising a cache hit; every other fake store in this file always misses.
- def test_serves_a_translation_from_cache_without_calling_the_adapter
- api = FakeApi.new([])
- configure_with(api, AllCachedStore.new(["Какая-то строка"]))
-
- result = TranslationDiff::Request.new("Some string", from: :en, to: :ru).call
-
- assert_equal "Какая-то строка", result
- assert_empty api.calls
- end
-
- # `provider:` picks the provider for one call; the configured provider is left untouched and unused.
- def test_the_provider_keyword_overrides_the_configured_provider_for_one_call
- api = FakeApi.new(%w[Один])
- configure_with(api)
-
- result = TranslationDiff::Request.new("One", from: :en, to: :ru, provider: :echo).call
-
- assert_equal "One", result
- assert_empty api.calls
- end
-
- # The `provider:` keyword is the third way to supply one; it must fail in the same words as the other two.
- def test_a_provider_object_passed_for_one_call_that_is_not_a_provider_is_refused
- error = assert_raises(TranslationDiff::InvalidProviderError) do
- TranslationDiff::Request.new("One", from: :en, to: :ru, provider: Object.new).call
- end
-
- assert_match(/TranslationDiff::Provider/, error.message)
- end
-
- # An empty cache-key segment would put this provider's translations in every other provider's namespace.
- def test_a_provider_whose_cache_key_is_empty_is_refused_rather_than_sharing_a_namespace
- configure_with(NamelessApi.new(%w[Один]))
-
- error = assert_raises(TranslationDiff::Request::Error) do
- TranslationDiff::Request.new("One", from: :en, to: :ru).call
- end
-
- assert_match(/must define #cache_key/, error.message)
- end
-
- def test_a_provider_whose_cache_key_is_whitespace_is_refused_rather_than_sharing_a_namespace
- configure_with(WhitespaceNamedApi.new(%w[Один]))
-
- error = assert_raises(TranslationDiff::Request::Error) do
- TranslationDiff::Request.new("One", from: :en, to: :ru).call
- end
-
- assert_match(/must define #cache_key/, error.message)
- end
-
- private
-
- # Returns the translation and the API fake, so the call can be asserted on.
- def translate(values, response)
- api = FakeApi.new(response)
- configure_with(api)
-
- [TranslationDiff::Request.new(values, from: :en, to: :ru).call, api]
- end
-
- # An object assigned to `provider` or `cache` is used as-is, so a fake goes in exactly where a name would.
- def configure_with(api, store = FakeCacheStore.new)
- TranslationDiff.configure do |config|
- config.provider = api
- config.cache = store
- end
- end
-end
diff --git a/test/translation_diff/segment_test.rb b/test/translation_diff/segment_test.rb
new file mode 100644
index 0000000..94182a6
--- /dev/null
+++ b/test/translation_diff/segment_test.rb
@@ -0,0 +1,82 @@
+require "test_helper"
+
+class SegmentTest < Minitest::Test
+ def test_core_is_the_sentence_without_its_padding
+ assert_equal "It was getting dark.", TranslationDiff::Segment.new(" It was getting dark. ").core
+ end
+
+ def test_source_is_kept_exactly
+ assert_equal " It was getting dark. ", TranslationDiff::Segment.new(" It was getting dark. ").source
+ end
+
+ def test_render_puts_the_translation_back_inside_the_original_padding
+ segment = TranslationDiff::Segment.new(" It was getting dark. ")
+ segment.translation = "Смеркалось."
+
+ assert_equal " Смеркалось. ", segment.render
+ end
+
+ # The padding is whatever was there, not a normalised guess at it.
+ def test_padding_is_reproduced_character_for_character
+ segment = TranslationDiff::Segment.new("\n\t One. \n")
+ segment.translation = "Один."
+
+ assert_equal "\n\t Один. \n", segment.render
+ end
+
+ def test_an_untranslated_segment_renders_its_source
+ assert_equal " One. ", TranslationDiff::Segment.new(" One. ").render
+ end
+
+ def test_translated_reports_whether_a_translation_was_set
+ segment = TranslationDiff::Segment.new("One.")
+
+ refute_predicate segment, :translated?
+ segment.translation = "Один."
+ assert_predicate segment, :translated?
+ end
+
+ # A run of whitespace between two sentences is a segment with nothing to
+ # translate. It must render unchanged and never reach a provider.
+ def test_a_segment_of_only_whitespace_is_empty
+ segment = TranslationDiff::Segment.new(" \n ")
+
+ assert_predicate segment, :empty?
+ assert_equal " \n ", segment.render
+ end
+
+ def test_a_segment_with_words_is_not_empty
+ refute_predicate TranslationDiff::Segment.new(" One. "), :empty?
+ end
+
+ def test_an_empty_source_is_empty
+ assert_predicate TranslationDiff::Segment.new(""), :empty?
+ end
+
+ # String literals are mutable in this project -- the magic comment was
+ # removed everywhere -- so a segment must not alias the string it was given.
+ def test_mutating_the_source_afterwards_does_not_change_the_segment
+ source = +" One. "
+ segment = TranslationDiff::Segment.new(source)
+ source << "trailing junk"
+
+ assert_equal " One. ", segment.source
+ assert_equal "One.", segment.core
+ assert_equal " One. ", segment.render
+ end
+
+ def test_a_segment_of_only_a_non_breaking_space_is_empty
+ segment = TranslationDiff::Segment.new(" ")
+
+ assert_predicate segment, :empty?
+ assert_equal " ", segment.render
+ end
+
+ def test_a_non_breaking_space_around_a_sentence_is_padding
+ segment = TranslationDiff::Segment.new(" One. ")
+ segment.translation = "Один."
+
+ assert_equal "One.", segment.core
+ assert_equal " Один. ", segment.render
+ end
+end
diff --git a/test/translation_diff/sentence_cache_test.rb b/test/translation_diff/sentence_cache_test.rb
new file mode 100644
index 0000000..c70f8bb
--- /dev/null
+++ b/test/translation_diff/sentence_cache_test.rb
@@ -0,0 +1,208 @@
+require "test_helper"
+
+class SentenceCacheTest < Minitest::Test
+ # Records what it is asked for, so a test can assert on keys as well as
+ # values. Minitest 6 has no mocking library; this is the whole contract.
+ class RecordingStore
+ attr_reader :reads, :writes
+
+ def initialize(values = {})
+ @values = values
+ @reads = []
+ @writes = {}
+ end
+
+ def read_multi(keys)
+ @reads.concat(keys)
+ keys.map { |k| @values[k] }
+ end
+
+ def write(key, value) = @writes[key] = value
+ end
+
+ def cache(store, **)
+ TranslationDiff::SentenceCache.new(
+ store: store, provider: "deepl", from: "en", to: "ru", **
+ )
+ end
+
+ def segments(*sources) = sources.map { |s| TranslationDiff::Segment.new(s) }
+
+ def test_a_hit_fills_the_segment_and_is_not_returned_as_a_miss
+ subject = segments("One.")
+ store = RecordingStore.new
+ key = cache(store).key(subject.first)
+ store = RecordingStore.new(key => "Один.")
+
+ misses = cache(store).fill(subject)
+
+ assert_equal "Один.", subject.first.translation
+ assert_empty misses
+ end
+
+ def test_a_miss_is_returned_and_left_untranslated
+ subject = segments("One.")
+
+ misses = cache(RecordingStore.new).fill(subject)
+
+ assert_equal subject, misses
+ refute_predicate subject.first, :translated?
+ end
+
+ def test_store_writes_only_the_translated_ones
+ subject = segments("One.", "Two.")
+ subject.first.translation = "Один."
+ store = RecordingStore.new
+ subject_cache = cache(store)
+
+ subject_cache.store(subject)
+
+ assert_equal ["Один."], store.writes.values
+ end
+
+ # The bug this replaces: the old cache consumed the array of updates it was
+ # handed, emptying a collection that belonged to its caller.
+ def test_neither_operation_modifies_the_collection_it_is_given
+ subject = segments("One.", "Two.")
+ original = subject.dup
+
+ subject_cache = cache(RecordingStore.new)
+ subject_cache.fill(subject)
+ subject_cache.store(subject)
+
+ assert_equal original, subject
+ assert_equal 2, subject.size
+ end
+
+ def test_the_same_sentence_in_two_languages_gets_two_keys
+ segment = segments("One.").first
+ en_ru = cache(RecordingStore.new).key(segment)
+ en_de = TranslationDiff::SentenceCache.new(
+ store: RecordingStore.new, provider: "deepl", from: "en", to: "de"
+ ).key(segment)
+
+ refute_equal en_ru, en_de
+ end
+
+ def test_two_providers_do_not_share_a_key
+ segment = segments("One.").first
+ deepl = cache(RecordingStore.new).key(segment)
+ google = TranslationDiff::SentenceCache.new(
+ store: RecordingStore.new, provider: "google", from: "en", to: "ru"
+ ).key(segment)
+
+ refute_equal deepl, google
+ end
+
+ def test_per_call_options_are_part_of_the_key
+ segment = segments("One.").first
+ plain = cache(RecordingStore.new).key(segment)
+ formal = cache(RecordingStore.new, options: { formality: :more }).key(segment)
+
+ refute_equal plain, formal
+ end
+
+ # The promise that a released cache keeps working. These values come from
+ # the baseline Task 1 recorded against the pipeline being replaced.
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength
+ def test_keys_match_the_ones_the_previous_pipeline_produced
+ subject_cache = TranslationDiff::SentenceCache.new(
+ store: RecordingStore.new, provider: "null", from: "en", to: "ru"
+ )
+
+ assert_equal "null:en:ru:9d6a2963872077db674a27a39c492e61",
+ subject_cache.key(segments("Hello there.").first)
+ assert_equal "null:en:ru:1520f71fffb5adf0da75e7c17059bfd1",
+ subject_cache.key(segments("Second sentence!").first)
+ assert_equal "null:en:ru:900019fa233e608091ba641d50d69b81",
+ subject_cache.key(segments("One.").first)
+ assert_equal "null:en:ru:fdb02803abc46fba06ce1cc96d6399c5",
+ subject_cache.key(segments("Two.").first)
+ assert_equal "null:en:ru:3f77101fc43570a61d5bc042bb908651",
+ subject_cache.key(segments("Third.").first)
+ assert_equal "null:en:ru:fe3bf43723a64fb32bcac8d99bb431af",
+ subject_cache.key(segments(" Padded sentence. ").first)
+ assert_equal "null:en:ru:05d12994070fdde458e566149f42472f",
+ subject_cache.key(segments("Salt & pepper.").first)
+ assert_equal "null:en:ru:b5010567e209726a125c9ed59162eca5",
+ subject_cache.key(segments("Fine.").first)
+ end
+
+ # Four fields without options, five with: the options digest is its own field, not folded into the sentence.
+ def test_the_options_digest_is_a_field_of_its_own
+ segment = segments("One.").first
+
+ assert_equal 4, cache(RecordingStore.new).key(segment).split(":").size
+ assert_equal 5, cache(RecordingStore.new, options: { formality: :more }).key(segment).split(":").size
+ end
+
+ # Recovered from the pipeline being replaced by running it against a recording store: one option, two
+ # options, and two whose sort order is not their literal order, which is the pair that proves the sort.
+ def test_the_options_digest_matches_the_keys_the_previous_pipeline_produced
+ segment = segments("One.").first
+
+ { { formality: :more } => "null:en:ru:c09f3c46:900019fa233e608091ba641d50d69b81",
+ { formality: :more, glossary_id: "g1" } => "null:en:ru:c1ee2461:900019fa233e608091ba641d50d69b81",
+ { b: 2, a: 1 } => "null:en:ru:9dc867b7:900019fa233e608091ba641d50d69b81" }.each do |options, expected|
+ subject_cache = TranslationDiff::SentenceCache.new(
+ store: RecordingStore.new, provider: "null", from: "en", to: "ru", options: options
+ )
+
+ assert_equal expected, subject_cache.key(segment)
+ end
+ end
+
+ # Container values recurse: a Hash canonicalises the way the options hash itself does, an Array in order.
+ # The first two were captured from the pipeline being replaced; the third goes a level deeper than either.
+ def test_container_option_values_recurse_the_way_the_previous_pipeline_did
+ segment = segments("One.").first
+
+ { { glossary: { a: 1, b: 2 } } => "null:en:ru:4dbc310c:900019fa233e608091ba641d50d69b81",
+ { tags: %w[x y] } => "null:en:ru:6b1bf7d6:900019fa233e608091ba641d50d69b81",
+ { glossary: { a: [1, 2] } } => "null:en:ru:1948f701:900019fa233e608091ba641d50d69b81" }.each do |options, key|
+ subject_cache = TranslationDiff::SentenceCache.new(
+ store: RecordingStore.new, provider: "null", from: "en", to: "ru", options: options
+ )
+
+ assert_equal key, subject_cache.key(segment)
+ end
+ end
+
+ # An allowlist, not a denylist: a tidy #inspect with no address in it is still one the old pipeline refused.
+ def test_a_value_outside_the_permitted_types_raises_even_when_its_inspect_is_stable
+ subject_cache = cache(RecordingStore.new, options: { glossary: Struct.new(:x).new(1) })
+
+ error = assert_raises(TranslationDiff::SentenceCache::Error) { subject_cache.key(segments("One.").first) }
+
+ assert_includes error.message, "glossary"
+ end
+
+ # A Symbol and a String are not comparable with each other, so sorting on the raw keys raises on this hash.
+ def test_mixed_option_key_types_canonicalise_instead_of_raising
+ segment = segments("One.").first
+ symbol_first = cache(RecordingStore.new, options: { formality: :more, "glossary" => "g" })
+ string_first = cache(RecordingStore.new, options: { "glossary" => "g", formality: :more })
+
+ assert_equal symbol_first.key(segment), string_first.key(segment)
+ end
+
+ # A value rendering as an address gives a key that can never be hit twice, so it fails where a user can see it.
+ def test_an_option_with_no_stable_string_form_raises_and_names_it
+ subject_cache = cache(RecordingStore.new, options: { glossary: Object.new })
+
+ error = assert_raises(TranslationDiff::SentenceCache::Error) { subject_cache.key(segments("One.").first) }
+
+ assert_kind_of TranslationDiff::Error, error
+ assert_includes error.message, "glossary"
+ end
+
+ # One definition of padding, the one Segment already makes: the key hashes the body it cut, not a copy of its regex.
+ def test_the_key_hashes_the_body_segment_cut
+ segment = segments(" Padded sentence. ").first
+
+ key = cache(RecordingStore.new).key(segment)
+
+ assert_equal "Padded sentence.", segment.body
+ assert_equal Digest::MD5.hexdigest(segment.body), key.split(":").last
+ end
+end
diff --git a/test/translation_diff/spacing_test.rb b/test/translation_diff/spacing_test.rb
deleted file mode 100644
index fa9df39..0000000
--- a/test/translation_diff/spacing_test.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-require "test_helper"
-
-class SpacingTest < Minitest::Test
- def test_restores_trailing_spaces
- assert_equal "А ", TranslationDiff::Spacing.restore("a ", "А")
- end
-
- def test_restores_leading_and_trailing_spaces
- assert_equal " Б ", TranslationDiff::Spacing.restore(" b ", "Б")
- end
-end
diff --git a/test/translation_diff/tokenizer_test.rb b/test/translation_diff/tokenizer_test.rb
deleted file mode 100644
index 24d5e07..0000000
--- a/test/translation_diff/tokenizer_test.rb
+++ /dev/null
@@ -1,156 +0,0 @@
-require "test_helper"
-
-class TokenizerTest < Minitest::Test
- PLAIN_TEXT = "test\nphrase".freeze
-
- NESTED_NOTRANSLATE = "foo" \
- "barbaz" \
- "".freeze
-
- # source => expected tokens
- CASES = {
- "an_empty_source" => [
- "",
- []
- ],
- "markup_holding_nothing_but_a_line_break" => [
- "\n
",
- [["", :markup], ["
", :markup]]
- ],
- "text_without_markup" => [
- PLAIN_TEXT,
- [[PLAIN_TEXT, :text]]
- ],
- "markup_ending_with_text" => [
- "alfabravokilo",
- [
- ["alfa", :text],
- ["", :markup],
- ["bravo", :text],
- ["", :markup],
- ["kilo", :text]
- ]
- ],
- "markup_ending_with_a_tag" => [
- "alfabravo",
- [
- ["alfa", :text],
- ["", :markup],
- ["bravo", :text],
- ["", :markup]
- ]
- ],
- "non_ascii_text_around_script_and_style" => [
- "альбракил",
- [
- ["аль", :text],
- ["", :markup],
- ["бра", :text],
- ["", :markup],
- ["кил", :text],
- ["", :markup]
- ]
- ],
- "text_split_into_sentences" => [
- "! Киловольт. Смеркалось. Ворчало. Кричало.",
- [
- # A lone terminator with no preceding content stays merged: a missed boundary, not a false one.
- ["! Киловольт. ", :text],
- ["", :markup],
- ["Смеркалось. ", :text],
- ["Ворчало. ", :text],
- ["Кричало.", :text],
- ["", :markup]
- ]
- ],
- "notranslate_spans_kept_as_text" => [
- "test\nxy",
- [
- ["test", :text],
- ["", :markup],
- ["\nxy", :text],
- ["", :markup]
- ]
- ],
- "a_notranslate_span_inside_another_span" => [
- "foobar
baz",
- [
- ["", :markup],
- ["foobar
baz", :text],
- ["", :markup]
- ]
- ],
- "a_notranslate_span_inside_another_notranslate_span" => [
- NESTED_NOTRANSLATE,
- [[NESTED_NOTRANSLATE, :text]]
- ],
- "a_br_tag_before_a_closing_tag" => [
- "Смеркалось.
",
- [
- ["", :markup],
- ["Смеркалось.", :text],
- ["
", :markup]
- ]
- ],
- "a_processing_instruction" => [
- "Hey!
Look!",
- [
- ["Hey!", :text],
- ["
", :markup],
- ["Look!", :text],
- ["", :markup]
- ]
- ],
- # Without a handler, the bytes a comment/doctype/CDATA event covers vanish from the rebuilt string.
- "an_html_comment" => [
- " Visible text.",
- [
- ["", :markup],
- [" Visible text.", :text]
- ]
- ],
- "a_doctype" => [
- "Body text.
",
- [
- ["", :markup],
- ["Body text.", :text],
- ["
", :markup]
- ]
- ],
- "a_cdata_section" => [
- "Before.After.",
- [
- ["Before.", :text],
- ["", :markup],
- ["After.", :text]
- ]
- ],
- "a_comment_between_two_sentences" => [
- "First sentence. Second sentence.",
- [
- ["First sentence. ", :text],
- ["", :markup],
- [" Second sentence.", :text]
- ]
- ],
- "sentences_separated_by_blank_lines" => [
- "Набор «Солнечная механика» от 4М — это 6 экспериментов." \
- "\n\nЮному изобретателю предстоит воочию посмотреть на чудеса.",
- [
- ["Набор «Солнечная механика» от 4М — это 6 экспериментов.\n\n", :text],
- ["Юному изобретателю предстоит воочию посмотреть на чудеса.", :text]
- ]
- ]
- }.freeze
-
- CASES.each do |name, (source, expected)|
- define_method(:"test_tokenizes_#{name}") do
- assert_equal expected, TranslationDiff::Tokenizer.tokenize(source, segmenter: segmenter)
- end
- end
-
- private
-
- # Passed explicitly now that the tokenizer takes its segmenter as a collaborator, not a global.
- def segmenter = TranslationDiff::Segmenters::Pragmatic.new
-end
diff --git a/test/translation_diff/translation/response_test.rb b/test/translation_diff/translation/response_test.rb
index 1ce7f7e..96bc511 100644
--- a/test/translation_diff/translation/response_test.rb
+++ b/test/translation_diff/translation/response_test.rb
@@ -25,7 +25,7 @@ def test_build_raises_when_the_provider_returned_the_wrong_number_of_texts
assert_match(/2/, error.message)
end
- # A nil translation used to reach Spacing.restore and die there as NoMethodError, naming nothing.
+ # A nil translation used to reach the old pipeline's spacing step and die there as NoMethodError, naming nothing.
def test_build_raises_when_a_translation_is_not_a_string
error = assert_raises(TranslationDiff::ResponseError) do
TranslationDiff::Translation::Response.build(request: request, texts: ["один", nil])
diff --git a/test/translation_diff/translator_test.rb b/test/translation_diff/translator_test.rb
new file mode 100644
index 0000000..e84fbab
--- /dev/null
+++ b/test/translation_diff/translator_test.rb
@@ -0,0 +1,361 @@
+require "test_helper"
+
+class TranslatorTest < 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
+
+ class FakeLogger
+ attr_reader :lines
+
+ def initialize = @lines = []
+
+ def debug(&) = @lines << yield
+ end
+
+ class Recorder
+ attr_reader :events
+
+ def initialize = @events = []
+
+ def instrument(name, payload)
+ @events << [name, payload]
+ yield if block_given?
+ end
+ end
+
+ # Always lets the call through, so the `rate_limit` event fires without a real Redis connection.
+ class FakeRateLimiter
+ attr_reader :sizes
+
+ def initialize = @sizes = []
+
+ def check(size) = @sizes << size
+ end
+
+ def setup
+ super
+ @provider = RecordingProvider.new(TranslationDiff::Configuration.new)
+ # Pinned so a developer with REDIS_URL set doesn't have these tests open a real socket.
+ TranslationDiff.configure { |c| c.cache = :memory }
+ end
+
+ def translate(values, **)
+ TranslationDiff::Translator.new(values, provider: @provider, **).call
+ end
+
+ def test_it_translates_a_nested_structure_and_keeps_its_shape
+ result = translate({ title: "one.", body: ["two.", 42] }, from: "en", to: "ru")
+
+ assert_equal({ title: "ONE.", body: ["TWO.", 42] }, result)
+ end
+
+ def test_the_same_language_never_reaches_the_provider
+ assert_equal "one.", translate("one.", from: "en", to: "en")
+ assert_empty @provider.requests
+ end
+
+ def test_the_same_language_is_compared_across_string_and_symbol
+ assert_equal "one.", translate("one.", from: "EN", to: :en)
+ assert_empty @provider.requests
+ end
+
+ def test_a_value_with_nothing_to_translate_never_reaches_the_provider
+ assert_equal "", translate("", from: "en", to: "ru")
+ assert_nil translate(nil, from: "en", to: "ru")
+ assert_empty @provider.requests
+ end
+
+ def test_a_missing_source_language_is_detected
+ translate("one.", to: "ru")
+
+ assert_equal "en", @provider.requests.first.from
+ end
+
+ def test_per_call_options_reach_the_provider_untouched
+ translate("one.", from: "en", to: "ru", formality: :less)
+
+ assert_equal({ formality: :less }, @provider.requests.first.options)
+ end
+
+ def test_a_second_translation_of_the_same_sentence_is_served_from_cache
+ translate("one.", from: "en", to: "ru")
+ translate("one.", from: "en", to: "ru")
+
+ assert_equal 1, @provider.requests.size
+ end
+
+ def test_only_the_missing_sentences_are_sent
+ translate("one. two.", from: "en", to: "ru")
+ translate("one. three.", from: "en", to: "ru")
+
+ assert_equal [%w[one. two.], %w[three.]], @provider.requests.map(&:texts)
+ end
+
+ # The old check was `respond_to?(:detect)`, satisfied by inheriting the base class's raising stub.
+ def test_a_provider_that_cannot_detect_says_so_before_it_is_called
+ @provider = BlindProvider.new(TranslationDiff::Configuration.new)
+
+ error = assert_raises(TranslationDiff::Translator::Error) { translate("one.", to: "ru") }
+
+ assert_match(/cannot detect/, error.message)
+ assert_match(/blind/, error.message)
+ assert_empty @provider.requests
+ end
+
+ 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") }
+
+ assert_match(/must define #cache_key/, error.message)
+ end
+
+ def test_a_provider_named_for_one_call_overrides_the_configured_one
+ configured = RecordingProvider.new(TranslationDiff::Configuration.new)
+ TranslationDiff.configure { |c| c.provider = configured }
+
+ assert_equal "ONE.", translate("one.", from: "en", to: "ru")
+ assert_empty configured.requests
+ end
+
+ def test_without_a_provider_keyword_the_configured_provider_is_used
+ TranslationDiff.configure { |c| c.provider = @provider }
+
+ assert_equal "ONE.", TranslationDiff::Translator.new("one.", from: "en", to: "ru").call
+ assert_equal 1, @provider.requests.size
+ end
+
+ def test_an_object_that_is_not_a_provider_is_refused
+ assert_raises(TranslationDiff::InvalidProviderError) do
+ TranslationDiff::Translator.new("one.", from: "en", to: "ru", provider: Object.new).call
+ end
+ end
+
+ def test_a_translation_emits_translate_cache_request_and_rate_limit_events
+ recorder = instrumented { |c| c.rate_limiter = FakeRateLimiter.new }
+ instrumented_translate("Hello there.")
+
+ assert_equal ALL_EVENT_NAMES, recorder.events.map(&:first).sort
+ end
+
+ def test_the_translate_event_carries_languages_provider_and_a_count
+ recorder = instrumented
+ instrumented_translate(%w[one two])
+
+ payload = payload_for(recorder, "translate")
+
+ assert_equal "en", payload[:from]
+ assert_equal "ru", payload[:to]
+ assert_equal "recording", payload[:provider]
+ assert_equal 2, payload[:values]
+ end
+
+ def test_the_cache_event_carries_hit_and_miss_counts
+ recorder = instrumented
+ instrumented_translate("Hello there.")
+
+ payload = payload_for(recorder, "cache")
+
+ assert_equal 0, payload[:hits]
+ assert_equal 1, payload[:misses]
+ assert_equal "recording", payload[:provider]
+ end
+
+ def test_the_request_event_carries_the_provider_a_batch_size_and_a_character_count
+ recorder = instrumented
+ instrumented_translate("Hello there.")
+
+ payload = payload_for(recorder, "request")
+
+ assert_equal "recording", payload[:provider]
+ assert_equal 1, payload[:batch]
+ assert_equal "Hello there.".size, payload[:characters]
+ end
+
+ def test_the_rate_limit_event_carries_the_provider_and_a_character_count
+ limiter = FakeRateLimiter.new
+ recorder = instrumented { |c| c.rate_limiter = limiter }
+ instrumented_translate("Hello there.")
+
+ payload = payload_for(recorder, "rate_limit")
+
+ assert_equal "recording", payload[:provider]
+ assert_equal "Hello there.".size, payload[:characters]
+ assert_equal ["Hello there.".size], limiter.sizes
+ end
+
+ # The limiter is consulted with what is about to be sent, before it is sent.
+ def test_the_rate_limiter_is_consulted_before_the_provider
+ limiter = FakeRateLimiter.new
+ recorder = instrumented { |c| c.rate_limiter = limiter }
+ instrumented_translate("Hello there.")
+
+ names = recorder.events.map(&:first).select { |name| name.start_with?("rate_limit", "request") }
+
+ assert_equal %w[rate_limit.translation_diff request.translation_diff], names
+ end
+
+ def test_no_rate_limit_event_without_a_rate_limiter
+ recorder = instrumented
+ instrumented_translate("Hello there.")
+
+ refute_includes recorder.events.map(&:first), "rate_limit.translation_diff"
+ end
+
+ # A call that returns early reaches no provider and so reports nothing.
+ def test_a_call_that_returns_early_emits_no_events
+ recorder = instrumented
+ instrumented_translate("Hello there.", to: "en")
+
+ assert_empty recorder.events
+ end
+
+ ALL_EVENT_NAMES = %w[translate.translation_diff cache.translation_diff
+ request.translation_diff rate_limit.translation_diff].sort.freeze
+
+ # A guard that only checked payload content would pass even if an event quietly stopped firing.
+ def test_no_payload_ever_contains_the_text_being_translated
+ secret = "Zaphod Beeblebrox is president."
+ recorder = instrumented { |c| c.rate_limiter = FakeRateLimiter.new }
+ instrumented_translate(secret)
+
+ assert_equal ALL_EVENT_NAMES, recorder.events.map(&:first).sort
+
+ serialised = recorder.events.map { |name, payload| "#{name}#{payload}" }.join
+ refute_includes serialised, "Zaphod"
+ refute_includes serialised, secret
+ refute_includes serialised, secret.upcase
+ end
+
+ # Captured from the old pipeline: a nested nil has always come back as "" from a call that reached a provider.
+ def test_a_nested_nil_collapses_to_an_empty_string
+ assert_equal({ a: "ONE.", n: 42, skip: "" },
+ translate({ a: "one.", n: 42, skip: nil }, from: "en", to: "ru"))
+ assert_equal ["ONE.", "", 42], translate(["one.", nil, 42], from: "en", to: "ru")
+ end
+
+ # And only then: a call with nothing to translate hands the caller's value back exactly as it was given.
+ def test_a_nil_survives_a_call_that_translates_nothing
+ assert_nil translate(nil, from: "en", to: "ru")
+ assert_equal [nil], translate([nil], from: "en", to: "ru")
+ assert_equal({ a: nil }, translate({ a: nil }, from: "en", to: "ru"))
+ end
+
+ # A subscriber grouping by payload[:to] must not see :ru and "ru" as two different series.
+ def test_the_translate_event_reports_languages_as_strings_whatever_the_caller_passed
+ recorder = instrumented
+ TranslationDiff::Translator.new("Hello there.", from: :en, to: :ru, provider: @provider).call
+
+ payload = payload_for(recorder, "translate")
+
+ assert_equal "en", payload[:from]
+ assert_equal "ru", payload[:to]
+ end
+
+ # `values` is the size of the document as the caller wrote it, not the number of translatable strings in it.
+ def test_the_translate_event_counts_every_leaf_the_caller_wrote
+ recorder = instrumented
+ instrumented_translate({ a: "one.", n: 42, skip: nil })
+
+ assert_equal 3, payload_for(recorder, "translate")[:values]
+ end
+
+ def test_a_value_with_nothing_to_translate_never_resolves_a_provider
+ assert_equal 42, TranslationDiff::Translator.new(42, from: "en", to: "ru", provider: Object.new).call
+ end
+
+ # A call that hands the caller's value straight back should not need a provider to do it: an app that has
+ # configured none at all still gets its value. Configured with something that is not a provider rather than
+ # left unset, so resolving one raises whatever the developer has in their environment.
+ def test_the_same_language_does_not_even_resolve_a_provider
+ TranslationDiff.configure { |c| c.provider = Object.new }
+
+ assert_equal "Hello", TranslationDiff::Translator.new("Hello", from: :ru, to: :ru).call
+ end
+
+ def test_a_value_with_nothing_to_translate_does_not_resolve_the_configured_provider_either
+ TranslationDiff.configure { |c| c.provider = Object.new }
+
+ assert_equal 42, TranslationDiff::Translator.new(42, from: "en", to: "ru").call
+ end
+
+ def test_the_provider_is_logged_once_and_only_when_it_is_resolved
+ logger = FakeLogger.new
+ TranslationDiff.configure { |c| c.logger = logger }
+
+ translate(42, from: "en", to: "ru")
+
+ assert_empty logger.lines
+
+ translate("one.", from: "en", to: "ru")
+
+ assert_equal 1, logger.lines.size
+ assert_match(/RecordingProvider/, logger.lines.first)
+ end
+
+ # A nil target used to reach the cache key and die there on #downcase; it is a caller's mistake, not a defect.
+ def test_a_missing_target_language_is_refused_by_name
+ error = assert_raises(ArgumentError) { translate("one.", from: "en") }
+
+ assert_match(/to:/, error.message)
+ end
+
+ private
+
+ def instrumented
+ Recorder.new.tap do |recorder|
+ TranslationDiff.configure do |c|
+ c.instrumenter = recorder
+ yield c if block_given?
+ end
+ end
+ end
+
+ def instrumented_translate(values, from: "en", to: "ru")
+ TranslationDiff::Translator.new(values, from: from, to: to, provider: @provider).call
+ end
+
+ def payload_for(recorder, event)
+ recorder.events.find { |name, _| name == "#{event}.translation_diff" }.last
+ end
+end
diff --git a/test/translation_diff_test.rb b/test/translation_diff_test.rb
index 0b4f449..bf62fea 100644
--- a/test/translation_diff_test.rb
+++ b/test/translation_diff_test.rb
@@ -1,6 +1,15 @@
require "test_helper"
class TranslationDiffTest < ConfiguredTest
+ def setup
+ super
+ TranslationDiff.configure do |c|
+ c.provider = :null
+ # Pinned so a developer with REDIS_URL set doesn't have these tests reach for a socket.
+ c.cache = :memory
+ end
+ end
+
def test_has_a_version_number
refute_nil TranslationDiff::VERSION
end
@@ -8,4 +17,27 @@ def test_has_a_version_number
def test_the_default_segmenter_is_pragmatic
assert_instance_of TranslationDiff::Segmenters::Pragmatic, TranslationDiff.config.segmenter_instance
end
+
+ # The entry point builds a Translator, so a nested value comes back with its shape and its non-strings intact.
+ def test_translate_runs_the_values_through_the_pipeline
+ result = TranslationDiff.translate({ title: "One. Two.", count: 42 }, from: "en", to: "ru")
+
+ assert_equal({ title: "One. Two.", count: 42 }, result)
+ end
+
+ # A caller who splats the same options hash into every call must get the same hash back out of it.
+ def test_repeated_calls_leave_the_callers_options_hash_alone
+ options = { from: :en, to: :ru }
+
+ 2.times { assert_equal "Some string.", TranslationDiff.translate("Some string.", **options) }
+
+ assert_equal({ from: :en, to: :ru }, options)
+ end
+
+ # `to:` still defaults to nil in the signature, so the keyword it names is what the caller has to read.
+ def test_a_missing_target_language_is_refused_by_name
+ error = assert_raises(ArgumentError) { TranslationDiff.translate("One.", from: "en") }
+
+ assert_match(/to:/, error.message)
+ end
end