App-karr
view release on metacpan or search on metacpan
t/70-utf8-roundtrip.t view on Meta::CPAN
use strict;
use warnings;
use Test::More;
use lib 't/lib';
use TestGit qw( require_git_c );
require_git_c();
use TestKarr qw( run_karr );
use File::Temp qw( tempdir );
use Cwd qw( abs_path );
use Path::Tiny qw( path );
use JSON::MaybeXS qw( decode_json );
use Encode qw( encode_utf8 decode FB_CROAK LEAVE_SRC );
use App::karr::Git;
use App::karr::Task;
use App::karr::Encoding qw( repair_mojibake );
# Ticket #53: karr mixed character strings and UTF-8 octets. YAML::XS::Dump
# emits octets and Load wants them, Path::Tiny's slurp_utf8/spew_utf8 work in
# characters, and @ARGV was never decoded -- so the frontmatter in every ref was
# encoded twice, `--json` handed agents mojibake, materialize wrote three
# encodes deep, and a correctly encoded kanban-md file could not be imported at
# all ("invalid trailing UTF-8 octet"). `karr show` looked right only because
# two errors cancelled.
#
# The contract now is one line: characters inside, octets only at the edges
# (App::karr::Encoding). This file walks a non-ASCII card the whole way round --
# argv, ref, show, --json, materialize, import, ref -- and asserts on the
# *bytes* at every edge, because any assertion that decodes what karr encoded is
# an identity round trip and would stay green under a consistent mis-encoding
# (ticket #63).
# The expectations here are character strings, so a failure diagnostic printing
# one would otherwise warn "Wide character in print" and show the wrong bytes --
# in an encoding test that is the worst possible time for illegible output.
binmode( Test::More->builder->$_, ':encoding(UTF-8)' )
for qw( output failure_output todo_output );
my $ROOT = abs_path('.');
my $TITLE = "Fix \x{dc}nicode \x{2014} \x{e4}rger";
my $BODY = "Caf\x{e9} \x{2014} na\x{ef}ve";
my $TAG = "gr\x{fc}n";
# In-process runner (t/lib/TestKarr.pm): same ($cwd, @argv) signature and
# { exit, stdout, stderr } return as the open3 helper this file used to carry,
# dispatched through the shared App::karr::Dispatch path. The encode_utf8(...)
# calls below hand it raw octets already; TestKarr passes an argv element
# through untouched unless it still carries the utf8 flag, so this file's
# whole point -- exact bytes at the argv edge -- still holds in-process.
# KARR_TEST_SUBPROC=1 restores the old open3 path.
sub _run_karr { return run_karr(@_) }
sub _init_repo {
my $repo = tempdir( CLEANUP => 1 );
system( 'git', 'init', '-q', $repo );
system( 'git', '-C', $repo, 'config', 'user.email', 'test@example.com' );
system( 'git', '-C', $repo, 'config', 'user.name', 'Test User' );
return $repo;
}
# The stored bytes, straight out of git and untouched by any karr code path.
sub _blob {
my ( $repo, $ref ) = @_;
open my $fh, '-|', 'git', '-C', $repo, 'cat-file', '-p', "$ref:data"
or die "git cat-file: $!";
binmode $fh;
my $raw = do { local $/; <$fh> };
close $fh;
return defined $raw ? $raw : '';
}
# "Encoded exactly once" as an assertion: the text is present as UTF-8 octets,
# the double encode of it is not, and the payload as a whole is valid UTF-8
# (which rules out three encodes and any partial decode).
sub is_single_utf8 {
my ( $bytes, $text, $name ) = @_;
my $ok = 1;
$ok &&= ok( index( $bytes, encode_utf8($text) ) >= 0, "$name: present as UTF-8 octets" );
$ok &&= is( index( $bytes, encode_utf8( encode_utf8($text) ) ), -1, "$name: not double-encoded" );
$ok &&= ok( defined eval { decode( 'UTF-8', $bytes, FB_CROAK | LEAVE_SRC ) },
"$name: payload is valid UTF-8" );
diag( "offending bytes: " . unpack( 'H*', $bytes ) ) unless $ok;
return $ok;
}
subtest 'argv to ref: a non-ASCII card is stored as singly-encoded UTF-8' => sub {
my $repo = _init_repo();
is( _run_karr( $repo, 'init', '--name', encode_utf8("Board \x{fc}") )->{exit}, 0, 'board initialized' );
is(
_run_karr( $repo, 'create', encode_utf8($TITLE),
'--body', encode_utf8($BODY), '--tags', encode_utf8($TAG) )->{exit},
0, 'task created from UTF-8 argv'
);
my $blob = _blob( $repo, 'refs/karr/tasks/1/data' );
is_single_utf8( $blob, $TITLE, 'ref blob title' );
is_single_utf8( $blob, $BODY, 'ref blob body' );
is_single_utf8( $blob, $TAG, 'ref blob tag' );
is_single_utf8( _blob( $repo, 'refs/karr/config' ), "Board \x{fc}", 'config ref board name' );
# And the same bytes parse back to the characters that were typed.
my $git = App::karr::Git->new( dir => $repo );
my $task = $git->load_task_ref(1);
is( $task->title, $TITLE, 'title reads back as characters' );
is( $task->body, $BODY, 'body reads back as characters' );
is_deeply( $task->tags, [$TAG], 'tag reads back as characters' );
};
subtest 'ref to stdout: show and show --json are both singly encoded' => sub {
my $repo = _init_repo();
is( _run_karr( $repo, 'init', '--name', 'Show 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 $show = _run_karr( $repo, 'show', '1' );
is( $show->{exit}, 0, 'show exits 0' );
is_single_utf8( $show->{stdout}, $TITLE, 'show stdout title' );
is_single_utf8( $show->{stdout}, $BODY, 'show stdout body' );
# --json is the interface agents parse and the one #53 got wrong even while
# plain show looked correct.
my $json = _run_karr( $repo, 'show', '1', '--json' );
is( $json->{exit}, 0, 'show --json exits 0' );
is_single_utf8( $json->{stdout}, $TITLE, 'show --json title' );
is_single_utf8( $json->{stdout}, $BODY, 'show --json body' );
my $data = decode_json( $json->{stdout} );
is( $data->{title}, $TITLE, 'decoded json title' );
is( $data->{body}, $BODY, 'decoded json body' );
is_deeply( $data->{tags}, [$TAG], 'decoded json tag' );
my $list = _run_karr( $repo, 'list' );
is( $list->{exit}, 0, 'list exits 0' );
is_single_utf8( $list->{stdout}, $TITLE, 'list stdout title' );
};
subtest 'ref to file view and back: materialize, import, ref' => sub {
my $repo = _init_repo();
is( _run_karr( $repo, 'init', '--name', 'Round 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' );
is( _run_karr( $repo, 'materialize' )->{exit}, 0, 'materialize exits 0' );
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";
( run in 0.812 second using v1.01-cache-2.11-cpan-364913b4093 )