);
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.jsx b/app/javascript/mastodon/features/ui/components/media_modal.jsx
index fad08b6750..d38dc18045 100644
--- a/app/javascript/mastodon/features/ui/components/media_modal.jsx
+++ b/app/javascript/mastodon/features/ui/components/media_modal.jsx
@@ -145,6 +145,7 @@ class MediaModal extends ImmutablePureComponent {
const content = media.map((image) => {
const width = image.getIn(['meta', 'original', 'width']) || null;
const height = image.getIn(['meta', 'original', 'height']) || null;
+ const description = image.getIn(['translation', 'description']) || image.get('description');
if (image.get('type') === 'image') {
return (
@@ -153,7 +154,7 @@ class MediaModal extends ImmutablePureComponent {
src={image.get('url')}
width={width}
height={height}
- alt={image.get('description')}
+ alt={description}
lang={lang}
key={image.get('url')}
onClick={this.toggleNavigation}
@@ -176,7 +177,7 @@ class MediaModal extends ImmutablePureComponent {
volume={volume || 1}
onCloseVideo={onClose}
detailed
- alt={image.get('description')}
+ alt={description}
lang={lang}
key={image.get('url')}
/>
@@ -188,7 +189,7 @@ class MediaModal extends ImmutablePureComponent {
width={width}
height={height}
key={image.get('url')}
- alt={image.get('description')}
+ alt={description}
lang={lang}
onClick={this.toggleNavigation}
/>
diff --git a/app/javascript/mastodon/features/ui/components/video_modal.jsx b/app/javascript/mastodon/features/ui/components/video_modal.jsx
index 2cc88c0432..48c6301a5e 100644
--- a/app/javascript/mastodon/features/ui/components/video_modal.jsx
+++ b/app/javascript/mastodon/features/ui/components/video_modal.jsx
@@ -9,7 +9,7 @@ import Footer from 'mastodon/features/picture_in_picture/components/footer';
import Video from 'mastodon/features/video';
const mapStateToProps = (state, { statusId }) => ({
- language: state.getIn(['statuses', statusId, 'language']),
+ status: state.getIn(['statuses', statusId]),
});
class VideoModal extends ImmutablePureComponent {
@@ -17,7 +17,7 @@ class VideoModal extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
statusId: PropTypes.string,
- language: PropTypes.string,
+ status: ImmutablePropTypes.map,
options: PropTypes.shape({
startTime: PropTypes.number,
autoPlay: PropTypes.bool,
@@ -38,8 +38,10 @@ class VideoModal extends ImmutablePureComponent {
}
render () {
- const { media, statusId, language, onClose } = this.props;
+ const { media, status, onClose } = this.props;
const options = this.props.options || {};
+ const language = status.getIn(['translation', 'language']) || status.get('language');
+ const description = media.getIn(['translation', 'description']) || media.get('description');
return (
@@ -55,13 +57,13 @@ class VideoModal extends ImmutablePureComponent {
onCloseVideo={onClose}
autoFocus
detailed
- alt={media.get('description')}
+ alt={description}
lang={language}
/>
- {statusId && }
+ {status && }
);
diff --git a/app/javascript/mastodon/reducers/polls.js b/app/javascript/mastodon/reducers/polls.js
index 901fdc449d..5e8e775dac 100644
--- a/app/javascript/mastodon/reducers/polls.js
+++ b/app/javascript/mastodon/reducers/polls.js
@@ -2,14 +2,43 @@ import { Map as ImmutableMap, fromJS } from 'immutable';
import { POLLS_IMPORT } from 'mastodon/actions/importer';
+import { normalizePollOptionTranslation } from '../actions/importer/normalizer';
+import { STATUS_TRANSLATE_SUCCESS, STATUS_TRANSLATE_UNDO } from '../actions/statuses';
+
const importPolls = (state, polls) => state.withMutations(map => polls.forEach(poll => map.set(poll.id, fromJS(poll))));
+const statusTranslateSuccess = (state, pollTranslation) => {
+ return state.withMutations(map => {
+ if (pollTranslation) {
+ const poll = state.get(pollTranslation.id);
+
+ pollTranslation.options.forEach((item, index) => {
+ map.setIn([pollTranslation.id, 'options', index, 'translation'], fromJS(normalizePollOptionTranslation(item, poll)));
+ });
+ }
+ });
+};
+
+const statusTranslateUndo = (state, id) => {
+ return state.withMutations(map => {
+ const options = map.getIn([id, 'options']);
+
+ if (options) {
+ options.forEach((item, index) => map.deleteIn([id, 'options', index, 'translation']));
+ }
+ });
+};
+
const initialState = ImmutableMap();
export default function polls(state = initialState, action) {
switch(action.type) {
case POLLS_IMPORT:
return importPolls(state, action.polls);
+ case STATUS_TRANSLATE_SUCCESS:
+ return statusTranslateSuccess(state, action.translation.poll);
+ case STATUS_TRANSLATE_UNDO:
+ return statusTranslateUndo(state, action.pollId);
default:
return state;
}
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
index fc5d31ab78..3c3d3d7114 100644
--- a/app/javascript/mastodon/reducers/statuses.js
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -1,6 +1,7 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
+import { normalizeStatusTranslation } from '../actions/importer/normalizer';
import {
REBLOG_REQUEST,
REBLOG_FAIL,
@@ -36,6 +37,27 @@ const deleteStatus = (state, id, references) => {
return state.delete(id);
};
+const statusTranslateSuccess = (state, id, translation) => {
+ return state.withMutations(map => {
+ map.setIn([id, 'translation'], fromJS(normalizeStatusTranslation(translation, map.get(id))));
+
+ const list = map.getIn([id, 'media_attachments']);
+ if (translation.media_attachments && list) {
+ translation.media_attachments.forEach(item => {
+ const index = list.findIndex(i => i.get('id') === item.id);
+ map.setIn([id, 'media_attachments', index, 'translation'], fromJS({ description: item.description }));
+ });
+ }
+ });
+};
+
+const statusTranslateUndo = (state, id) => {
+ return state.withMutations(map => {
+ map.deleteIn([id, 'translation']);
+ map.getIn([id, 'media_attachments']).forEach((item, index) => map.deleteIn([id, 'media_attachments', index, 'translation']));
+ });
+};
+
const initialState = ImmutableMap();
export default function statuses(state = initialState, action) {
@@ -87,9 +109,9 @@ export default function statuses(state = initialState, action) {
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references);
case STATUS_TRANSLATE_SUCCESS:
- return state.setIn([action.id, 'translation'], fromJS(action.translation));
+ return statusTranslateSuccess(state, action.id, action.translation);
case STATUS_TRANSLATE_UNDO:
- return state.deleteIn([action.id, 'translation']);
+ return statusTranslateUndo(state, action.id);
default:
return state;
}
diff --git a/app/lib/emoji_formatter.rb b/app/lib/emoji_formatter.rb
index dd9a0e5d75..8c3856d897 100644
--- a/app/lib/emoji_formatter.rb
+++ b/app/lib/emoji_formatter.rb
@@ -12,6 +12,7 @@ class EmojiFormatter
# @param [Hash] options
# @option options [Boolean] :animate
# @option options [String] :style
+ # @option options [String] :raw_shortcode
def initialize(html, custom_emojis, options = {})
raise ArgumentError unless html.html_safe?
@@ -43,7 +44,7 @@ class EmojiFormatter
next unless (char_after.nil? || !DISALLOWED_BOUNDING_REGEX.match?(char_after)) && (emoji = emoji_map[shortcode])
result << Nokogiri::XML::Text.new(text[last_index..shortname_start_index - 1], tree.document) if shortname_start_index.positive?
- result << Nokogiri::HTML.fragment(image_for_emoji(shortcode, emoji))
+ result << Nokogiri::HTML.fragment(tag_for_emoji(shortcode, emoji))
last_index = i + 1
elsif text[i] == ':' && (i.zero? || !DISALLOWED_BOUNDING_REGEX.match?(text[i - 1]))
@@ -75,7 +76,9 @@ class EmojiFormatter
end
end
- def image_for_emoji(shortcode, emoji)
+ def tag_for_emoji(shortcode, emoji)
+ return content_tag(:span, ":#{shortcode}:", translate: 'no') if raw_shortcode?
+
original_url, static_url = emoji
image_tag(
@@ -103,4 +106,8 @@ class EmojiFormatter
def animate?
@options[:animate] || @options.key?(:style)
end
+
+ def raw_shortcode?
+ @options[:raw_shortcode]
+ end
end
diff --git a/app/lib/translation_service/deepl.rb b/app/lib/translation_service/deepl.rb
index afcb7ecb2f..925a1cf172 100644
--- a/app/lib/translation_service/deepl.rb
+++ b/app/lib/translation_service/deepl.rb
@@ -10,8 +10,8 @@ class TranslationService::DeepL < TranslationService
@api_key = api_key
end
- def translate(text, source_language, target_language)
- form = { text: text, source_lang: source_language&.upcase, target_lang: target_language, tag_handling: 'html' }
+ def translate(texts, source_language, target_language)
+ form = { text: texts, source_lang: source_language&.upcase, target_lang: target_language, tag_handling: 'html' }
request(:post, '/v2/translate', form: form) do |res|
transform_response(res.body_with_limit)
end
@@ -67,12 +67,17 @@ class TranslationService::DeepL < TranslationService
end
end
- def transform_response(str)
- json = Oj.load(str, mode: :strict)
+ def transform_response(json)
+ data = Oj.load(json, mode: :strict)
+ raise UnexpectedResponseError unless data.is_a?(Hash)
- raise UnexpectedResponseError unless json.is_a?(Hash)
-
- Translation.new(text: json.dig('translations', 0, 'text'), detected_source_language: json.dig('translations', 0, 'detected_source_language')&.downcase, provider: 'DeepL.com')
+ data['translations'].map do |translation|
+ Translation.new(
+ text: translation['text'],
+ detected_source_language: translation['detected_source_language']&.downcase,
+ provider: 'DeepL.com'
+ )
+ end
rescue Oj::ParseError
raise UnexpectedResponseError
end
diff --git a/app/lib/translation_service/libre_translate.rb b/app/lib/translation_service/libre_translate.rb
index 8bb194a9c2..de43d7c88c 100644
--- a/app/lib/translation_service/libre_translate.rb
+++ b/app/lib/translation_service/libre_translate.rb
@@ -8,8 +8,8 @@ class TranslationService::LibreTranslate < TranslationService
@api_key = api_key
end
- def translate(text, source_language, target_language)
- body = Oj.dump(q: text, source: source_language.presence || 'auto', target: target_language, format: 'html', api_key: @api_key)
+ def translate(texts, source_language, target_language)
+ body = Oj.dump(q: texts, source: source_language.presence || 'auto', target: target_language, format: 'html', api_key: @api_key)
request(:post, '/translate', body: body) do |res|
transform_response(res.body_with_limit, source_language)
end
@@ -44,12 +44,17 @@ class TranslationService::LibreTranslate < TranslationService
end
end
- def transform_response(str, source_language)
- json = Oj.load(str, mode: :strict)
+ def transform_response(json, source_language)
+ data = Oj.load(json, mode: :strict)
+ raise UnexpectedResponseError unless data.is_a?(Hash)
- raise UnexpectedResponseError unless json.is_a?(Hash)
-
- Translation.new(text: json['translatedText'], detected_source_language: source_language, provider: 'LibreTranslate')
+ data['translatedText'].map.with_index do |text, index|
+ Translation.new(
+ text: text,
+ detected_source_language: data.dig('detectedLanguage', index, 'language') || source_language,
+ provider: 'LibreTranslate'
+ )
+ end
rescue Oj::ParseError
raise UnexpectedResponseError
end
diff --git a/app/models/translation.rb b/app/models/translation.rb
new file mode 100644
index 0000000000..7f8469c86e
--- /dev/null
+++ b/app/models/translation.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class Translation < ActiveModelSerializers::Model
+ attributes :status, :detected_source_language, :language, :provider,
+ :content, :spoiler_text, :poll_options, :media_attachments
+
+ class Option < ActiveModelSerializers::Model
+ attributes :title
+ end
+
+ class MediaAttachment < ActiveModelSerializers::Model
+ attributes :id, :description
+ end
+end
diff --git a/app/serializers/rest/translation_serializer.rb b/app/serializers/rest/translation_serializer.rb
index 05ededc95c..40e2d28fb7 100644
--- a/app/serializers/rest/translation_serializer.rb
+++ b/app/serializers/rest/translation_serializer.rb
@@ -1,9 +1,38 @@
# frozen_string_literal: true
class REST::TranslationSerializer < ActiveModel::Serializer
- attributes :content, :detected_source_language, :provider
+ attributes :detected_source_language, :language, :provider, :spoiler_text, :content
- def content
- object.text
+ class PollSerializer < ActiveModel::Serializer
+ attribute :id
+ has_many :options
+
+ def id
+ object.status.preloadable_poll.id.to_s
+ end
+
+ def options
+ object.poll_options
+ end
+
+ class OptionSerializer < ActiveModel::Serializer
+ attributes :title
+ end
+ end
+
+ has_one :poll, serializer: PollSerializer
+
+ class MediaAttachmentSerializer < ActiveModel::Serializer
+ attributes :id, :description
+
+ def id
+ object.id.to_s
+ end
+ end
+
+ has_many :media_attachments, serializer: MediaAttachmentSerializer
+
+ def poll
+ object if object.status.preloadable_poll
end
end
diff --git a/app/services/translate_status_service.rb b/app/services/translate_status_service.rb
index 796f13a0dd..c2b40433ed 100644
--- a/app/services/translate_status_service.rb
+++ b/app/services/translate_status_service.rb
@@ -3,16 +3,24 @@
class TranslateStatusService < BaseService
CACHE_TTL = 1.day.freeze
+ include ERB::Util
include FormattingHelper
def call(status, target_language)
@status = status
- @content = status_content_format(@status)
+ @source_texts = source_texts
@target_language = target_language
raise Mastodon::NotPermittedError unless permitted?
- Rails.cache.fetch("translations/#{@status.language}/#{@target_language}/#{content_hash}", expires_in: CACHE_TTL) { translation_backend.translate(@content, @status.language, @target_language) }
+ status_translation = Rails.cache.fetch("v2:translations/#{@status.language}/#{@target_language}/#{content_hash}", expires_in: CACHE_TTL) do
+ translations = translation_backend.translate(@source_texts.values, @status.language, @target_language)
+ build_status_translation(translations)
+ end
+
+ status_translation.status = @status
+
+ status_translation
end
private
@@ -22,7 +30,7 @@ class TranslateStatusService < BaseService
end
def permitted?
- return false unless @status.distributable? && @status.content.present? && TranslationService.configured?
+ return false unless @status.distributable? && TranslationService.configured?
languages[@status.language]&.include?(@target_language)
end
@@ -32,6 +40,73 @@ class TranslateStatusService < BaseService
end
def content_hash
- Digest::SHA256.base64digest(@content)
+ Digest::SHA256.base64digest(@source_texts.transform_keys { |key| key.respond_to?(:id) ? "#{key.class}-#{key.id}" : key }.to_json)
+ end
+
+ def source_texts
+ texts = {}
+ texts[:content] = wrap_emoji_shortcodes(status_content_format(@status)) if @status.content.present?
+ texts[:spoiler_text] = wrap_emoji_shortcodes(html_escape(@status.spoiler_text)) if @status.spoiler_text.present?
+
+ @status.preloadable_poll&.loaded_options&.each do |option|
+ texts[option] = wrap_emoji_shortcodes(html_escape(option.title))
+ end
+
+ @status.media_attachments.each do |media_attachment|
+ texts[media_attachment] = html_escape(media_attachment.description)
+ end
+
+ texts
+ end
+
+ def build_status_translation(translations)
+ status_translation = Translation.new(
+ detected_source_language: translations.first&.detected_source_language,
+ language: @target_language,
+ provider: translations.first&.provider,
+ content: '',
+ spoiler_text: '',
+ poll_options: [],
+ media_attachments: []
+ )
+
+ @source_texts.keys.each_with_index do |source, index|
+ translation = translations[index]
+
+ case source
+ when :content
+ status_translation.content = unwrap_emoji_shortcodes(translation.text).to_html
+ when :spoiler_text
+ status_translation.spoiler_text = unwrap_emoji_shortcodes(translation.text).content
+ when Poll::Option
+ status_translation.poll_options << Translation::Option.new(
+ title: unwrap_emoji_shortcodes(translation.text).content
+ )
+ when MediaAttachment
+ status_translation.media_attachments << Translation::MediaAttachment.new(
+ id: source.id,
+ description: html_entities.decode(translation.text)
+ )
+ end
+ end
+
+ status_translation
+ end
+
+ def wrap_emoji_shortcodes(text)
+ EmojiFormatter.new(text, @status.emojis, { raw_shortcode: true }).to_s
+ end
+
+ def unwrap_emoji_shortcodes(html)
+ fragment = Nokogiri::HTML.fragment(html)
+ fragment.css('span[translate="no"]').each do |element|
+ element.remove_attribute('translate')
+ element.replace(element.children) if element.attributes.empty?
+ end
+ fragment
+ end
+
+ def html_entities
+ HTMLEntities.new
end
end
diff --git a/spec/controllers/api/v1/statuses/translations_controller_spec.rb b/spec/controllers/api/v1/statuses/translations_controller_spec.rb
index 8495779bf3..989e94750a 100644
--- a/spec/controllers/api/v1/statuses/translations_controller_spec.rb
+++ b/spec/controllers/api/v1/statuses/translations_controller_spec.rb
@@ -19,7 +19,7 @@ describe Api::V1::Statuses::TranslationsController do
before do
translation = TranslationService::Translation.new(text: 'Hello')
- service = instance_double(TranslationService::DeepL, translate: translation)
+ service = instance_double(TranslationService::DeepL, translate: [translation])
allow(TranslationService).to receive(:configured?).and_return(true)
allow(TranslationService).to receive(:configured).and_return(service)
Rails.cache.write('translation_service/languages', { 'es' => ['en'] })
diff --git a/spec/lib/translation_service/deepl_spec.rb b/spec/lib/translation_service/deepl_spec.rb
index 2363f8f139..5a1d0f094a 100644
--- a/spec/lib/translation_service/deepl_spec.rb
+++ b/spec/lib/translation_service/deepl_spec.rb
@@ -22,7 +22,10 @@ RSpec.describe TranslationService::DeepL do
.with(body: 'text=Hasta+la+vista&source_lang=ES&target_lang=en&tag_handling=html')
.to_return(body: '{"translations":[{"detected_source_language":"ES","text":"See you soon"}]}')
- translation = service.translate('Hasta la vista', 'es', 'en')
+ translations = service.translate(['Hasta la vista'], 'es', 'en')
+ expect(translations.size).to eq 1
+
+ translation = translations.first
expect(translation.detected_source_language).to eq 'es'
expect(translation.provider).to eq 'DeepL.com'
expect(translation.text).to eq 'See you soon'
@@ -31,12 +34,27 @@ RSpec.describe TranslationService::DeepL do
it 'returns translation with auto-detected source language' do
stub_request(:post, 'https://api.deepl.com/v2/translate')
.with(body: 'text=Guten+Tag&source_lang&target_lang=en&tag_handling=html')
- .to_return(body: '{"translations":[{"detected_source_language":"DE","text":"Good Morning"}]}')
+ .to_return(body: '{"translations":[{"detected_source_language":"DE","text":"Good morning"}]}')
- translation = service.translate('Guten Tag', nil, 'en')
+ translations = service.translate(['Guten Tag'], nil, 'en')
+ expect(translations.size).to eq 1
+
+ translation = translations.first
expect(translation.detected_source_language).to eq 'de'
expect(translation.provider).to eq 'DeepL.com'
- expect(translation.text).to eq 'Good Morning'
+ expect(translation.text).to eq 'Good morning'
+ end
+
+ it 'returns translation of multiple texts' do
+ stub_request(:post, 'https://api.deepl.com/v2/translate')
+ .with(body: 'text=Guten+Morgen&text=Gute+Nacht&source_lang=DE&target_lang=en&tag_handling=html')
+ .to_return(body: '{"translations":[{"detected_source_language":"DE","text":"Good morning"},{"detected_source_language":"DE","text":"Good night"}]}')
+
+ translations = service.translate(['Guten Morgen', 'Gute Nacht'], 'de', 'en')
+ expect(translations.size).to eq 2
+
+ expect(translations.first.text).to eq 'Good morning'
+ expect(translations.last.text).to eq 'Good night'
end
end
diff --git a/spec/lib/translation_service/libre_translate_spec.rb b/spec/lib/translation_service/libre_translate_spec.rb
index fbd726a7ea..90966a8ebf 100644
--- a/spec/lib/translation_service/libre_translate_spec.rb
+++ b/spec/lib/translation_service/libre_translate_spec.rb
@@ -31,24 +31,42 @@ RSpec.describe TranslationService::LibreTranslate do
describe '#translate' do
it 'returns translation with specified source language' do
stub_request(:post, 'https://libretranslate.example.com/translate')
- .with(body: '{"q":"Hasta la vista","source":"es","target":"en","format":"html","api_key":"my-api-key"}')
- .to_return(body: '{"translatedText": "See you"}')
+ .with(body: '{"q":["Hasta la vista"],"source":"es","target":"en","format":"html","api_key":"my-api-key"}')
+ .to_return(body: '{"translatedText": ["See you"]}')
- translation = service.translate('Hasta la vista', 'es', 'en')
- expect(translation.detected_source_language).to eq 'es'
+ translations = service.translate(['Hasta la vista'], 'es', 'en')
+ expect(translations.size).to eq 1
+
+ translation = translations.first
+ expect(translation.detected_source_language).to be 'es'
expect(translation.provider).to eq 'LibreTranslate'
expect(translation.text).to eq 'See you'
end
it 'returns translation with auto-detected source language' do
stub_request(:post, 'https://libretranslate.example.com/translate')
- .with(body: '{"q":"Guten Morgen","source":"auto","target":"en","format":"html","api_key":"my-api-key"}')
- .to_return(body: '{"detectedLanguage":{"confidence":92,"language":"de"},"translatedText":"Good morning"}')
+ .with(body: '{"q":["Guten Morgen"],"source":"auto","target":"en","format":"html","api_key":"my-api-key"}')
+ .to_return(body: '{"detectedLanguage": [{"confidence": 92, "language": "de"}], "translatedText": ["Good morning"]}')
- translation = service.translate('Guten Morgen', nil, 'en')
- expect(translation.detected_source_language).to be_nil
+ translations = service.translate(['Guten Morgen'], nil, 'en')
+ expect(translations.size).to eq 1
+
+ translation = translations.first
+ expect(translation.detected_source_language).to eq 'de'
expect(translation.provider).to eq 'LibreTranslate'
expect(translation.text).to eq 'Good morning'
end
+
+ it 'returns translation of multiple texts' do
+ stub_request(:post, 'https://libretranslate.example.com/translate')
+ .with(body: '{"q":["Guten Morgen","Gute Nacht"],"source":"de","target":"en","format":"html","api_key":"my-api-key"}')
+ .to_return(body: '{"translatedText": ["Good morning", "Good night"]}')
+
+ translations = service.translate(['Guten Morgen', 'Gute Nacht'], 'de', 'en')
+ expect(translations.size).to eq 2
+
+ expect(translations.first.text).to eq 'Good morning'
+ expect(translations.last.text).to eq 'Good night'
+ end
end
end
diff --git a/spec/services/translate_status_service_spec.rb b/spec/services/translate_status_service_spec.rb
new file mode 100644
index 0000000000..074f55544a
--- /dev/null
+++ b/spec/services/translate_status_service_spec.rb
@@ -0,0 +1,226 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe TranslateStatusService, type: :service do
+ subject(:service) { described_class.new }
+
+ let(:status) { Fabricate(:status, text: text, spoiler_text: spoiler_text, language: 'en', preloadable_poll: poll, media_attachments: media_attachments) }
+ let(:text) { 'Hello' }
+ let(:spoiler_text) { '' }
+ let(:poll) { nil }
+ let(:media_attachments) { [] }
+
+ before do
+ Fabricate(:custom_emoji, shortcode: 'highfive')
+ end
+
+ describe '#call' do
+ before do
+ translation_service = TranslationService.new
+ allow(translation_service).to receive(:languages).and_return({ 'en' => ['es'] })
+ allow(translation_service).to receive(:translate) do |texts|
+ texts.map do |text|
+ TranslationService::Translation.new(
+ text: text.gsub('Hello', 'Hola').gsub('higfive', 'cincoaltos'),
+ detected_source_language: 'en',
+ provider: 'Dummy'
+ )
+ end
+ end
+
+ allow(TranslationService).to receive(:configured?).and_return(true)
+ allow(TranslationService).to receive(:configured).and_return(translation_service)
+ end
+
+ it 'returns translated status content' do
+ expect(service.call(status, 'es').content).to eq '