App-karr
view release on metacpan or search on metacpan
t/70-utf8-roundtrip.t view on Meta::CPAN
my ($card) = path($repo)->child('tasks')->children(qr/\.md$/);
ok( $card, 'a card was written' );
my $raw = do { open my $fh, '<:raw', "$card" or die $!; local $/; <$fh> };
is_single_utf8( $raw, $TITLE, 'materialized file title' );
is_single_utf8( $raw, $BODY, 'materialized file body' );
my $cfg = do {
open my $fh, '<:raw', path($repo)->child('config.yml')->stringify or die $!;
local $/;
<$fh>;
};
ok( defined eval { decode( 'UTF-8', $cfg, FB_CROAK | LEAVE_SRC ) }, 'config.yml is valid UTF-8' );
is( App::karr::Task->from_file($card)->title, $TITLE, 'the file parses back to the characters' );
is( _run_karr( $repo, 'import', '--yes' )->{exit}, 0, 'import --yes exits 0' );
is( _blob( $repo, 'refs/karr/tasks/1/data' ), $before,
'materialize then import leaves the ref byte-identical' );
};
subtest 'karr reads a task file written by kanban-md' => sub {
# Not a file karr produced: a plain UTF-8 kanban-md card. Under #53 the whole
# import died with "YAML::XS::Load Error: invalid trailing UTF-8 octet", so
# the interop goal was broken for every non-ASCII board.
my $repo = _init_repo();
my $tasks = path($repo)->child('tasks');
$tasks->mkpath;
my $doc = join '',
"---\n",
"id: 1\n",
"title: " . $TITLE . "\n",
"status: backlog\n",
"priority: medium\n",
"class: standard\n",
"created: 2026-01-01T00:00:00Z\n",
"updated: 2026-01-01T00:00:00Z\n",
"tags:\n",
"- " . $TAG . "\n",
"---\n",
"\n",
$BODY . "\n";
{ open my $fh, '>:raw', $tasks->child('001-cafe.md')->stringify or die $!;
print {$fh} encode_utf8($doc);
close $fh }
my $direct = App::karr::Task->from_file( $tasks->child('001-cafe.md') );
is( $direct->title, $TITLE, 'from_file decodes a kanban-md title' );
is( $direct->body, $BODY, 'from_file decodes a kanban-md body' );
is_deeply( $direct->tags, [$TAG], 'from_file decodes a kanban-md tag' );
my $rv = _run_karr( $repo, 'import', '--yes' );
is( $rv->{exit}, 0, 'import --yes succeeds on a kanban-md view' ) or diag $rv->{stderr};
unlike( $rv->{stderr}, qr/invalid trailing UTF-8 octet/, 'no YAML::XS decode error' );
is_single_utf8( _blob( $repo, 'refs/karr/tasks/1/data' ), $TITLE, 'imported ref title' );
is( App::karr::Git->new( dir => $repo )->load_task_ref(1)->title, $TITLE,
'and it reads back as the same characters' );
};
subtest 'backup and restore preserve a non-ASCII board' => sub {
my $repo = _init_repo();
is( _run_karr( $repo, 'init', '--name', 'Snap Board' )->{exit}, 0, 'board initialized' );
is(
_run_karr( $repo, 'create', encode_utf8($TITLE),
'--body', encode_utf8($BODY), '--tags', encode_utf8($TAG) )->{exit},
0, 'task created'
);
my $before = _blob( $repo, 'refs/karr/tasks/1/data' );
my $file = path($repo)->child('snapshot.yml');
is( _run_karr( $repo, 'backup', '--output', "$file" )->{exit}, 0, 'backup --output exits 0' );
my $raw = do { open my $fh, '<:raw', "$file" or die $!; local $/; <$fh> };
is_single_utf8( $raw, $TITLE, 'snapshot file title' );
is( _run_karr( $repo, 'restore', '--yes', '--input', "$file" )->{exit}, 0, 'restore exits 0' );
# Not a byte-for-byte comparison: Git::read_ref chomps the payload's trailing
# newline to match the old `git cat-file` behaviour, so a backup/restore cycle
# has always dropped exactly that one byte -- on ASCII boards too, and on
# 0.402 as well. That is a separate defect; what this subtest is about is that
# no character is mangled on the way through.
my $after = _blob( $repo, 'refs/karr/tasks/1/data' );
is( $after, ( $before =~ s/\n\z//r ), 'the restored ref is the original payload (less the chomped newline)' );
is_single_utf8( $after, $TITLE, 'restored ref title' );
is_single_utf8( $after, $BODY, 'restored ref body' );
my $task = App::karr::Git->new( dir => $repo )->load_task_ref(1);
is( $task->title, $TITLE, 'restored title reads back as characters' );
is( $task->body, $BODY, 'restored body reads back as characters' );
is_deeply( $task->tags, [$TAG], 'restored tag reads back as characters' );
};
subtest 'the two entry points install the boundary' => sub {
# The boundary only exists if the scripts set it up; a command body doing it
# for itself is exactly the per-call-site encoding #53 removed. Pinning the
# call sites keeps a future refactor from quietly dropping one. bin/karr's
# own call site moved into App::karr::Dispatch (shared with the in-process
# test runner, t/lib/TestKarr.pm); bin/karr-foundation still does it inline.
my $dispatch_src = path($ROOT)->child(qw( lib App karr Dispatch.pm ))->slurp_utf8;
like( $dispatch_src, qr/^\s*enable_std_utf8\(\);/m,
'App::karr::Dispatch puts the UTF-8 layer on stdout/stderr' );
like( $dispatch_src, qr/^\s*decode_argv\(\);/m, 'App::karr::Dispatch decodes @ARGV' );
my $karr_src = path($ROOT)->child(qw( bin karr ))->slurp_utf8;
like( $karr_src, qr/^dispatch\(\@ARGV\);/m, 'bin/karr calls into that dispatch path' );
my $foundation_src = path($ROOT)->child(qw( bin karr-foundation ))->slurp_utf8;
like( $foundation_src, qr/^enable_std_utf8\(\);/m,
'bin/karr-foundation puts the UTF-8 layer on stdout/stderr' );
like( $foundation_src, qr/^decode_argv\(\);/m, 'bin/karr-foundation decodes @ARGV' );
};
subtest 'repair_mojibake only touches what is unambiguously double-encoded' => sub {
# The one heuristic in the encoding boundary, used for boards written by
# 0.402 or earlier.
# Its safety rests on these four cases.
is( repair_mojibake('plain ascii'), 'plain ascii', 'ASCII is returned unchanged' );
is( repair_mojibake($TITLE), $TITLE, 'text with a real wide character is left alone' );
# Latin-1 that is not valid UTF-8 when read back as bytes: "\x{fc}ber" is
# fc 62, an unfinished sequence. Nothing to undo, so nothing is undone.
is( repair_mojibake("\x{fc}ber"), "\x{fc}ber", 'lone Latin-1 text is left alone' );
# The actual corruption: the UTF-8 octets of $TITLE read as Latin-1.
my $broken = decode( 'ISO-8859-1', encode_utf8($TITLE) );
isnt( $broken, $TITLE, 'the fixture really is mojibake to begin with' );
is( repair_mojibake($broken), $TITLE, 'double-encoded text is repaired' );
is_deeply(
repair_mojibake( { title => $broken, tags => [ decode( 'ISO-8859-1', encode_utf8($TAG) ) ], id => 4 } ),
{ title => $TITLE, tags => [$TAG], id => 4 },
'hashes and arrays are walked, non-strings pass through'
);
is( repair_mojibake( repair_mojibake($broken) ), $TITLE, 'repairing an already-repaired string is a no-op' );
};
done_testing;
( run in 0.541 second using v1.01-cache-2.11-cpan-4ef0a570458 )