@@ -, +, @@ __("Hi") __x("Hi {name}", name => 'Bob') __n("item", "items", $num_items) __nx("one item", "{count} items", $num_items, count => $num_items) __p("Bibliographic record", "item") a way to visualize the result (pass result to the template for example, or simply warn and watch the log file) string(s) You may need to change the "Plural-Forms" header. See https://localization-guide.readthedocs.org/en/latest/l10n/pluralforms.html translation, change language and verify the translation is correct their "__" equivalent, with one difference: the 'x' variants take a hashref instead of a hash as last parameter string(s) translation, change language and verify the translation is correct --- C4/Installer/PerlDependencies.pm | 12 +- Koha/I18N.pm | 191 +++++++++++++++++--- Koha/Template/Plugin/I18N.pm | 72 ++++++++ koha-tmpl/intranet-tmpl/prog/en/includes/i18n.inc | 39 ++++ koha-tmpl/opac-tmpl/bootstrap/en/includes/i18n.inc | 39 ++++ misc/translator/LangInstaller.pm | 201 ++++++++++++++++++--- t/Koha/I18N.t | 62 +++++++ t/Koha/I18N/po/xx_XX/LC_MESSAGES/Koha.mo | Bin 0 -> 1370 bytes 8 files changed, 557 insertions(+), 59 deletions(-) create mode 100644 Koha/Template/Plugin/I18N.pm create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/i18n.inc create mode 100644 koha-tmpl/opac-tmpl/bootstrap/en/includes/i18n.inc create mode 100755 t/Koha/I18N.t create mode 100644 t/Koha/I18N/po/xx_XX/LC_MESSAGES/Koha.mo --- a/C4/Installer/PerlDependencies.pm +++ a/C4/Installer/PerlDependencies.pm @@ -727,15 +727,15 @@ our $PERL_DEPS = { required => 1, min_ver => '2.125', }, - 'Locale::Maketext' => { + 'Locale::Messages' => { 'usage' => 'Core', 'required' => '1', - 'min_ver' => '1.19', + 'min_ver' => '1.20', }, - 'Locale::Maketext::Lexicon' => { - 'usage' => 'Core', - 'required' => '1', - 'min_ver' => '0.91', + 'PPI' => { + 'usage' => 'I18N', + 'required' => '0', + 'min_ver' => '1.215', }, 'LWP::Protocol::https' => { 'usage' => 'OverDrive integration', --- a/Koha/I18N.pm +++ a/Koha/I18N.pm @@ -18,41 +18,186 @@ package Koha::I18N; # along with Koha; if not, see . use Modern::Perl; -use base qw(Locale::Maketext Exporter); use CGI; use C4::Languages; +use C4::Context; -use Locale::Maketext::Lexicon { - 'en' => ['Auto'], - '*' => [ - Gettext => - C4::Context->config('intranetdir') - . '/misc/translator/po/*-messages.po' - ], - '_AUTO' => 1, - '_style' => 'gettext', -}; +use Encode; +use Locale::Messages qw(:locale_h nl_putenv setlocale LC_MESSAGES); +use Koha::Cache::Memory::Lite; -our @EXPORT = qw( gettext ); +use parent 'Exporter'; +our @EXPORT = qw( + __ + __x + __n + __nx + __xn + __p + __px + __np + __npx + N__ + N__n + N__p + N__np +); -my %language_handles; +our $textdomain = 'Koha'; -sub get_language_handle { - my $cgi = new CGI; - my $language = C4::Languages::getlanguage; +sub init { + my $cache = Koha::Cache::Memory::Lite->get_instance(); + my $cache_key = 'i18n:initialized'; + unless ($cache->get_from_cache($cache_key)) { + my @system_locales = grep { chomp; not (/^C/ || $_ eq 'POSIX') } qx/locale -a/; + if (@system_locales) { + # LANG needs to be set to a valid locale, + # otherwise LANGUAGE is ignored + nl_putenv('LANG=' . $system_locales[0]); + setlocale(LC_MESSAGES, ''); - if (not exists $language_handles{$language}) { - $language_handles{$language} = __PACKAGE__->get_handle($language) - or die "No language handle for '$language'"; + my $langtag = C4::Languages::getlanguage; + my @subtags = split /-/, $langtag; + my ($language, $region) = @subtags; + if ($region && length $region == 4) { + $region = $subtags[2]; + } + my $locale = $language; + if ($region) { + $locale .= '_' . $region; + } + + nl_putenv("LANGUAGE=$locale"); + nl_putenv('OUTPUT_CHARSET=UTF-8'); + + my $directory = _base_directory(); + textdomain($textdomain); + bindtextdomain($textdomain, $directory); + } else { + warn "No locale installed. Localization cannot work and is therefore disabled"; + } + + $cache->set_in_cache($cache_key, 1); + } +} + +sub __ { + my ($msgid) = @_; + + $msgid = Encode::encode_utf8($msgid); + + return _gettext(\&gettext, [ $msgid ]); +} + +sub __x { + my ($msgid, %vars) = @_; + + $msgid = Encode::encode_utf8($msgid); + + return _gettext(\&gettext, [ $msgid ], %vars); +} + +sub __n { + my ($msgid, $msgid_plural, $count) = @_; + + $msgid = Encode::encode_utf8($msgid); + $msgid_plural = Encode::encode_utf8($msgid_plural); + + return _gettext(\&ngettext, [ $msgid, $msgid_plural, $count ]); +} + +sub __nx { + my ($msgid, $msgid_plural, $count, %vars) = @_; + + $msgid = Encode::encode_utf8($msgid); + $msgid_plural = Encode::encode_utf8($msgid_plural); + + return _gettext(\&ngettext, [ $msgid, $msgid_plural, $count ], %vars); +} + +sub __xn { + return __nx(@_); +} + +sub __p { + my ($msgctxt, $msgid) = @_; + + $msgctxt = Encode::encode_utf8($msgctxt); + $msgid = Encode::encode_utf8($msgid); + + return _gettext(\&pgettext, [ $msgctxt, $msgid ]); +} + +sub __px { + my ($msgctxt, $msgid, %vars) = @_; + + $msgctxt = Encode::encode_utf8($msgctxt); + $msgid = Encode::encode_utf8($msgid); + + return _gettext(\&pgettext, [ $msgctxt, $msgid ], %vars); +} + +sub __np { + my ($msgctxt, $msgid, $msgid_plural, $count) = @_; + + $msgctxt = Encode::encode_utf8($msgctxt); + $msgid = Encode::encode_utf8($msgid); + $msgid_plural = Encode::encode_utf8($msgid_plural); + + return _gettext(\&npgettext, [ $msgctxt, $msgid, $msgid_plural, $count ]); +} + +sub __npx { + my ($msgctxt, $msgid, $msgid_plural, $count, %vars) = @_; + + $msgctxt = Encode::encode_utf8($msgctxt); + $msgid = Encode::encode_utf8($msgid); + $msgid_plural = Encode::encode_utf8($msgid_plural); + + return _gettext(\&npgettext, [ $msgctxt, $msgid, $msgid_plural, $count], %vars); +} + +sub N__ { + return @_; +} + +sub N__n { + return @_; +} + +sub N__p { + return @_; +} + +sub N__np { + return @_; +} + +sub _base_directory { + return C4::Context->config('intranetdir') . '/misc/translator/po'; +} + +sub _gettext { + my ($sub, $args, %vars) = @_; + + init(); + + my $text = Encode::decode_utf8($sub->(@$args)); + if (%vars) { + $text = _expand($text, %vars); } - return $language_handles{$language}; + return $text; } -sub gettext { - my $lh = get_language_handle; - $lh->maketext(@_); +sub _expand { + my ($text, %vars) = @_; + + my $re = join '|', map { quotemeta $_ } keys %vars; + $text =~ s/\{($re)\}/defined $vars{$1} ? $vars{$1} : "{$1}"/ge; + + return $text; } 1; --- a/Koha/Template/Plugin/I18N.pm +++ a/Koha/Template/Plugin/I18N.pm @@ -0,0 +1,72 @@ +package Koha::Template::Plugin::I18N; + +# Copyright BibLibre 2015 +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use base qw( Template::Plugin ); + +use C4::Context; +use Koha::I18N; + +sub t { + my ($self, $msgid) = @_; + return __($msgid); +} + +sub tx { + my ($self, $msgid, $vars) = @_; + return __x($msgid, %$vars); +} + +sub tn { + my ($self, $msgid, $msgid_plural, $count) = @_; + return __n($msgid, $msgid_plural, $count); +} + +sub tnx { + my ($self, $msgid, $msgid_plural, $count, $vars) = @_; + return __nx($msgid, $msgid_plural, $count, %$vars); +} + +sub txn { + my ($self, $msgid, $msgid_plural, $count, $vars) = @_; + return __xn($msgid, $msgid_plural, $count, %$vars); +} + +sub tp { + my ($self, $msgctxt, $msgid) = @_; + return __p($msgctxt, $msgid); +} + +sub tpx { + my ($self, $msgctxt, $msgid, $vars) = @_; + return __px($msgctxt, $msgid, %$vars); +} + +sub tnp { + my ($self, $msgctxt, $msgid, $msgid_plural, $count) = @_; + return __np($msgctxt, $msgid, $msgid_plural, $count); +} + +sub tnpx { + my ($self, $msgctxt, $msgid, $msgid_plural, $count, $vars) = @_; + return __np($msgctxt, $msgid, $msgid_plural, $count, %$vars); +} + +1; --- a/koha-tmpl/intranet-tmpl/prog/en/includes/i18n.inc +++ a/koha-tmpl/intranet-tmpl/prog/en/includes/i18n.inc @@ -0,0 +1,39 @@ +[% + USE I18N; + + MACRO t(msgid) BLOCK; + I18N.t(msgid); + END; + + MACRO tx(msgid, vars) BLOCK; + I18N.tx(msgid, vars); + END; + + MACRO tn(msgid, msgid_plural, count) BLOCK; + I18N.tn(msgid, msgid_plural, count); + END; + + MACRO tnx(msgid, msgid_plural, count, vars) BLOCK; + I18N.tnx(msgid, msgid_plural, count, vars); + END; + + MACRO txn(msgid, msgid_plural, count, vars) BLOCK; + I18N.txn(msgid, msgid_plural, count, vars); + END; + + MACRO tp(msgctxt, msgid) BLOCK; + I18N.tp(msgctxt, msgid); + END; + + MACRO tpx(msgctxt, msgid, vars) BLOCK; + I18N.tpx(msgctxt, msgid, vars); + END; + + MACRO tnp(msgctxt, msgid, msgid_plural, count) BLOCK; + I18N.tnp(msgctxt, msgid, msgid_plural, count); + END; + + MACRO tnpx(msgctxt, msgid, msgid_plural, count, vars) BLOCK; + I18N.tnpx(msgctxt, msgid, msgid_plural, count, vars); + END; +%] --- a/koha-tmpl/opac-tmpl/bootstrap/en/includes/i18n.inc +++ a/koha-tmpl/opac-tmpl/bootstrap/en/includes/i18n.inc @@ -0,0 +1,39 @@ +[% + USE I18N; + + MACRO t(msgid) BLOCK; + I18N.t(msgid); + END; + + MACRO tx(msgid, vars) BLOCK; + I18N.tx(msgid, vars); + END; + + MACRO tn(msgid, msgid_plural, count) BLOCK; + I18N.tn(msgid, msgid_plural, count); + END; + + MACRO tnx(msgid, msgid_plural, count, vars) BLOCK; + I18N.tnx(msgid, msgid_plural, count, vars); + END; + + MACRO txn(msgid, msgid_plural, count, vars) BLOCK; + I18N.txn(msgid, msgid_plural, count, vars); + END; + + MACRO tp(msgctxt, msgid) BLOCK; + I18N.tp(msgctxt, msgid); + END; + + MACRO tpx(msgctxt, msgid, vars) BLOCK; + I18N.tpx(msgctxt, msgid, vars); + END; + + MACRO tnp(msgctxt, msgid, msgid_plural, count) BLOCK; + I18N.tnp(msgctxt, msgid, msgid_plural, count); + END; + + MACRO tnpx(msgctxt, msgid, msgid_plural, count, vars) BLOCK; + I18N.tnpx(msgctxt, msgid, msgid_plural, count, vars); + END; +%] --- a/misc/translator/LangInstaller.pm +++ a/misc/translator/LangInstaller.pm @@ -25,6 +25,13 @@ use C4::Context; use YAML::Syck qw( Dump LoadFile ); use Locale::PO; use FindBin qw( $Bin ); +use File::Basename; +use File::Find; +use File::Path qw( make_path ); +use File::Slurp; +use File::Temp qw( tempdir ); +use Template::Parser; +use PPI; $YAML::Syck::ImplicitTyping = 1; @@ -66,13 +73,17 @@ sub new { $self->{process} = "$Bin/tmpl_process3.pl " . ($verbose ? '' : '-q'); $self->{path_po} = "$Bin/po"; $self->{po} = { '' => $default_pref_po_header }; - $self->{domain} = 'messages'; + $self->{domain} = 'Koha'; $self->{cp} = `which cp`; $self->{msgmerge} = `which msgmerge`; + $self->{msgfmt} = `which msgfmt`; + $self->{msginit} = `which msginit`; $self->{xgettext} = `which xgettext`; $self->{sed} = `which sed`; chomp $self->{cp}; chomp $self->{msgmerge}; + chomp $self->{msgfmt}; + chomp $self->{msginit}; chomp $self->{xgettext}; chomp $self->{sed}; @@ -456,30 +467,134 @@ sub create_tmpl { } } +sub locale_name { + my $self = shift; + + my ($language, $region, $country) = split /-/, $self->{lang}; + $country //= $region; + my $locale = $language; + if ($country && length($country) == 2) { + $locale .= '_' . $country; + } + + return $locale; +} + sub create_messages { my $self = shift; - print "Create messages ($self->{lang})\n" if $self->{verbose}; - system - "$self->{cp} $self->{domain}.pot " . - "$self->{path_po}/$self->{lang}-$self->{domain}.po"; + my $pot = "$self->{domain}.pot"; + my $po = "$self->{path_po}/$self->{lang}-messages.po"; + + unless ( -f $pot ) { + $self->extract_messages(); + } + + say "Create messages ($self->{lang})" if $self->{verbose}; + my $locale = $self->locale_name(); + system "$self->{msginit} -i $pot -o $po -l $locale --no-translator"; + + # If msginit failed to correctly set Plural-Forms, set a default one + system "$self->{sed} --in-place $po " + . "--expression='s/Plural-Forms: nplurals=INTEGER; plural=EXPRESSION/Plural-Forms: nplurals=2; plural=(n != 1)/'"; } sub update_messages { my $self = shift; - my $pofile = "$self->{path_po}/$self->{lang}-$self->{domain}.po"; - print "Update messages ($self->{lang})\n" if $self->{verbose}; - if ( not -f $pofile ) { - print "File $pofile does not exist\n" if $self->{verbose}; + my $pot = "$self->{domain}.pot"; + my $po = "$self->{path_po}/$self->{lang}-messages.po"; + + unless ( -f $pot ) { + $self->extract_messages(); + } + + if ( -f $po ) { + say "Update messages ($self->{lang})" if $self->{verbose}; + system "$self->{msgmerge} -U $po $pot"; + } else { $self->create_messages(); } - system "$self->{msgmerge} -U $pofile $self->{domain}.pot"; +} + +sub extract_messages_from_templates { + my ($self, $tempdir, @files) = @_; + + my $intranetdir = $self->{context}->config('intranetdir'); + my @keywords = qw(t tx tn txn tnx tp tpx tnp tnpx); + my $parser = Template::Parser->new(); + + foreach my $file (@files) { + say "Extract messages from $file" if $self->{verbose}; + my $template = read_file("$intranetdir/$file"); + my $data = $parser->parse($template); + unless ($data) { + warn "Error at $file : " . $parser->error(); + next; + } + + make_path(dirname("$tempdir/$file")); + open my $fh, '>', "$tempdir/$file"; + + my @blocks = ($data->{BLOCK}, values %{ $data->{DEFBLOCKS} }); + foreach my $block (@blocks) { + my $document = PPI::Document->new(\$block); + + # [% t('foo') %] is compiled to + # $output .= $stash->get(['t', ['foo']]); + # We try to find all nodes corresponding to keyword (here 't') + my $nodes = $document->find(sub { + my ($topnode, $element) = @_; + + # Filter out non-valid keywords + return 0 unless ($element->isa('PPI::Token::Quote::Single')); + return 0 unless (grep {$element->content eq qq{'$_'}} @keywords); + + # keyword (e.g. 't') should be the first element of the arrayref + # passed to $stash->get() + return 0 if $element->sprevious_sibling; + + return 0 unless $element->snext_sibling + && $element->snext_sibling->snext_sibling + && $element->snext_sibling->snext_sibling->isa('PPI::Structure::Constructor'); + + # Check that it's indeed a call to $stash->get() + my $statement = $element->statement->parent->statement->parent->statement; + return 0 unless grep { $_->isa('PPI::Token::Symbol') && $_->content eq '$stash' } $statement->children; + return 0 unless grep { $_->isa('PPI::Token::Operator') && $_->content eq '->' } $statement->children; + return 0 unless grep { $_->isa('PPI::Token::Word') && $_->content eq 'get' } $statement->children; + + return 1; + }); + + next unless $nodes; + + # Write the Perl equivalent of calls to t* functions family, so + # xgettext can extract the strings correctly + foreach my $node (@$nodes) { + my @args = map { + $_->significant && !$_->isa('PPI::Token::Operator') ? $_->content : () + } $node->snext_sibling->snext_sibling->find_first('PPI::Statement')->children; + + my $keyword = $node->content; + $keyword =~ s/^'t(.*)'$/__$1/; + + say $fh "$keyword(" . join(', ', @args) . ");"; + } + + } + + close $fh; + } + + return $tempdir; } sub extract_messages { my $self = shift; + say "Extract messages into POT file" if $self->{verbose}; + my $intranetdir = $self->{context}->config('intranetdir'); my @files_to_scan; my @directories_to_scan = ('.'); @@ -499,26 +614,53 @@ sub extract_messages { } } - my $xgettext_cmd = "$self->{xgettext} -L Perl --from-code=UTF-8 " . - "-o $Bin/$self->{domain}.pot -D $intranetdir"; + my @tt_files; + find(sub { + if ($File::Find::dir =~ m|/en/| && $_ =~ m/\.(tt|inc)$/) { + my $filename = $File::Find::name; + $filename =~ s|^$intranetdir/||; + push @tt_files, $filename; + } + }, "$intranetdir/koha-tmpl"); + + my $tempdir = tempdir('Koha-translate-XXXX', TMPDIR => 1, CLEANUP => 1); + $self->extract_messages_from_templates($tempdir, @tt_files); + push @files_to_scan, @tt_files; + + my $xgettext_cmd = "$self->{xgettext} -L Perl --from-code=UTF-8 " + . "--package-name=Koha --package-version='' " + . "-k -k__ -k__x -k__n:1,2 -k__nx:1,2 -k__xn:1,2 -k__p:1c,2 " + . "-k__px:1c,2 -k__np:1c,2,3 -k__npx:1c,2,3 -kN__ -kN__n:1,2 " + . "-kN__p:1c,2 -kN__np:1c,2,3 " + . "-o $Bin/$self->{domain}.pot -D $tempdir -D $intranetdir"; $xgettext_cmd .= " $_" foreach (@files_to_scan); if (system($xgettext_cmd) != 0) { die "system call failed: $xgettext_cmd"; } - if ( -f "$Bin/$self->{domain}.pot" ) { - my $replace_charset_cmd = "$self->{sed} --in-place " . - "$Bin/$self->{domain}.pot " . - "--expression='s/charset=CHARSET/charset=UTF-8/'"; - if (system($replace_charset_cmd) != 0) { - die "system call failed: $replace_charset_cmd"; - } - } else { - print "No messages found\n" if $self->{verbose}; - return; + my $replace_charset_cmd = "$self->{sed} --in-place " . + "$Bin/$self->{domain}.pot " . + "--expression='s/charset=CHARSET/charset=UTF-8/'"; + if (system($replace_charset_cmd) != 0) { + die "system call failed: $replace_charset_cmd"; } - return 1; +} + +sub install_messages { + my ($self) = @_; + + my $locale = $self->locale_name(); + my $modir = "$self->{path_po}/$locale/LC_MESSAGES"; + my $pofile = "$self->{path_po}/$self->{lang}-messages.po"; + my $mofile = "$modir/$self->{domain}.mo"; + + if ( not -f $pofile ) { + $self->create_messages(); + } + say "Install messages ($locale)" if $self->{verbose}; + make_path($modir); + system "$self->{msgfmt} -o $mofile $pofile"; } sub remove_pot { @@ -532,6 +674,8 @@ sub install { return unless $self->{lang}; $self->install_tmpl($files) unless $self->{pref_only}; $self->install_prefs(); + $self->install_messages(); + $self->remove_pot(); } @@ -547,14 +691,13 @@ sub get_all_langs { sub update { my ($self, $files) = @_; my @langs = $self->{lang} ? ($self->{lang}) : $self->get_all_langs(); - my $extract_ok = $self->extract_messages(); for my $lang ( @langs ) { $self->set_lang( $lang ); $self->update_tmpl($files) unless $self->{pref_only}; $self->update_prefs(); - $self->update_messages() if $extract_ok; + $self->update_messages(); } - $self->remove_pot() if $extract_ok; + $self->remove_pot(); } @@ -563,10 +706,8 @@ sub create { return unless $self->{lang}; $self->create_tmpl($files) unless $self->{pref_only}; $self->create_prefs(); - if ($self->extract_messages()) { - $self->create_messages(); - $self->remove_pot(); - } + $self->create_messages(); + $self->remove_pot(); } --- a/t/Koha/I18N.t +++ a/t/Koha/I18N.t @@ -0,0 +1,62 @@ +#!/usr/bin/perl + +use Modern::Perl; +use Test::More tests => 35; +use Test::MockModule; +use FindBin qw($Bin); +use Encode; + +BEGIN { + use_ok('Koha::I18N'); +} + +my $koha_i18n = Test::MockModule->new('Koha::I18N'); +$koha_i18n->mock('_base_directory', sub { "$Bin/I18N/po" }); + +my $c4_languages = Test::MockModule->new('C4::Languages'); +$c4_languages->mock('getlanguage', sub { 'xx-XX' }); + +# If you need to modify the MO file to add new tests +# 1) msgunfmt -o Koha.po t/Koha/I18N/po/xx_XX/LC_MESSAGES/Koha.mo +# 2) Edit Koha.po +# 3) msgfmt -o t/Koha/I18N/po/xx_XX/LC_MESSAGES/Koha.mo Koha.po +my @tests = ( + [ __('test') => 'test ツ' ], + [ __x('Hello {name}', name => 'World') => 'Hello World ツ' ], + [ __n('Singular', 'Plural', 0) => 'Zero ツ' ], + [ __n('Singular', 'Plural', 1) => 'Singular ツ' ], + [ __n('Singular', 'Plural', 2) => 'Plural ツ' ], + [ __n('Singular', 'Plural', 3) => 'Plural ツ' ], + [ __nx('one item', '{count} items', 0, count => 0) => 'no item ツ' ], + [ __nx('one item', '{count} items', 1, count => 1) => 'one item ツ' ], + [ __nx('one item', '{count} items', 2, count => 2) => '2 items ツ' ], + [ __nx('one item', '{count} items', 3, count => 3) => '3 items ツ' ], + [ __xn('one item', '{count} items', 0, count => 0) => 'no item ツ' ], + [ __xn('one item', '{count} items', 1, count => 1) => 'one item ツ' ], + [ __xn('one item', '{count} items', 2, count => 2) => '2 items ツ' ], + [ __xn('one item', '{count} items', 3, count => 3) => '3 items ツ' ], + [ __p('biblio', 'title') => 'title (biblio) ツ' ], + [ __p('patron', 'title') => 'title (patron) ツ' ], + [ __px('biblio', 'Remove item {id}', id => 42) => 'Remove item 42 (biblio) ツ' ], + [ __px('list', 'Remove item {id}', id => 42) => 'Remove item 42 (list) ツ' ], + [ __np('ctxt1', 'singular', 'plural', 0) => 'zero (ctxt1) ツ' ], + [ __np('ctxt1', 'singular', 'plural', 1) => 'singular (ctxt1) ツ' ], + [ __np('ctxt1', 'singular', 'plural', 2) => 'plural (ctxt1) ツ' ], + [ __np('ctxt1', 'singular', 'plural', 3) => 'plural (ctxt1) ツ' ], + [ __np('ctxt2', 'singular', 'plural', 0) => 'zero (ctxt2) ツ' ], + [ __np('ctxt2', 'singular', 'plural', 1) => 'singular (ctxt2) ツ' ], + [ __np('ctxt2', 'singular', 'plural', 2) => 'plural (ctxt2) ツ' ], + [ __np('ctxt2', 'singular', 'plural', 3) => 'plural (ctxt2) ツ' ], + [ __npx('biblio', 'one item', '{count} items', 0, count => 0) => 'no item (biblio) ツ' ], + [ __npx('biblio', 'one item', '{count} items', 1, count => 1) => 'one item (biblio) ツ' ], + [ __npx('biblio', 'one item', '{count} items', 2, count => 2) => '2 items (biblio) ツ' ], + [ __npx('biblio', 'one item', '{count} items', 3, count => 3) => '3 items (biblio) ツ' ], + [ __npx('list', 'one item', '{count} items', 0, count => 0) => 'no item (list) ツ' ], + [ __npx('list', 'one item', '{count} items', 1, count => 1) => 'one item (list) ツ' ], + [ __npx('list', 'one item', '{count} items', 2, count => 2) => '2 items (list) ツ' ], + [ __npx('list', 'one item', '{count} items', 3, count => 3) => '3 items (list) ツ' ], +); + +foreach my $test (@tests) { + is($test->[0], decode_utf8($test->[1]), $test->[1]); +}