Finance-Tiller2QIF
view release on metacpan or search on metacpan
t/interactive.t view on Meta::CPAN
use 5.036;
use utf8;
use warnings FATAL => 'utf8';
use open ':std', ':encoding(UTF-8)';
use Test2::V0;
use Capture::Tiny qw(capture);
use File::Temp qw(tempdir);
use Path::Tiny;
use Finance::Tiller2QIF;
use Finance::Tiller2QIF::Interactive;
use Finance::Tiller2QIF::Util;
use Finance::Tiller2QIF::DB qw(connect_db);
sub fixture () {
my $dir = tempdir(CLEANUP => 1);
# Match Run's canonical Windows paths so failure-injection targets compare
# equal despite File::Temp returning backslash-separated directory names.
$dir = path($dir)->realpath->stringify if $^O eq 'MSWin32';
my $f = { dir => $dir, db => "$dir/live.sqlite3", input => "$dir/input.csv",
output => "$dir/result.qif", mapfile => "$dir/rules.map" };
Finance::Tiller2QIF::Util::InitDB($f->{db});
path($f->{input})->spew_utf8(
"Date,Transaction ID,Account,Amount,Description,Full Description,Category\n"
. "04/25/2026,tx-α-123456789,Checking,100.00,Deposit,Paycheck,Income\n");
path($f->{mapfile})->spew_utf8("category | Income | Income:Salary\n");
path($f->{output})->spew_utf8("existing QIF\n");
$f->{original} = Finance::Tiller2QIF::Interactive::_digest($f->{db});
return $f;
}
sub unchanged ($f) {
is(Finance::Tiller2QIF::Interactive::_digest($f->{db}), $f->{original},
'original database bytes unchanged');
is(path($f->{output})->slurp_utf8, "existing QIF\n", 'existing QIF unchanged');
}
sub run_session ($f, $answers, $edit=undef, @extra) {
my $views = 0;
my $view = \&Finance::Tiller2QIF::Interactive::_view;
no warnings 'redefine';
local *Finance::Tiller2QIF::Interactive::_view = sub ($session) {
$f->{session} = $session;
$edit->($session, ++$views) if $edit;
$view->($session);
};
local @ARGV = ('interactive', '--db', $f->{db}, '--input', $f->{input},
'--output', $f->{output},
(defined $f->{mapfile} ? ('--mapfile', $f->{mapfile}) : ()), @extra);
open my $input, '<', \$answers or die $!;
local *STDIN = $input;
my ($result, $error);
my ($out, $err) = capture {
$result = eval { Finance::Tiller2QIF::run_cli() };
$error = $@;
};
return { out => $out, err => $err, error => $error, result => $result };
}
# These cases hit intermittent Capture::Tiny tempfile errors on one Windows
# tester. Keep them running as TODO until another tester reports the issue.
sub capture_subtest ($name, $code) {
my $todo = todo('Intermittent Capture::Tiny tempfile permission failure on Windows')
if $^O eq 'MSWin32';
subtest $name => $code;
}
subtest 'cancel and EOF keep originals, retain artifacts' => sub {
for my $answer ("c\n", '') {
my $f = fixture();
my $r = run_session($f, $answer);
is($r->{error}, '', 'session returns normally');
is($r->{result}, 0, 'cancel is unsuccessful');
unchanged($f);
ok(-f "$f->{session}{dir}/corrections.t2qsql", 'SQL retained with dedicated editor extension');
ok(-f $f->{session}{review}{db}, 'working database retained');
like($r->{out}, qr/ID: tx-α-123456789/, 'full transaction ID displayed');
}
};
subtest 'refresh reconsiders skips, replays hooks once, and preserves overrides' => sub {
my $f = fixture();
path($f->{mapfile})->spew_utf8("default | skip\n");
path("$f->{dir}/before.sql")->spew_utf8("UPDATE transactions SET amount = amount + 2;\n");
path("$f->{dir}/after.sql")->spew_utf8("UPDATE transactions SET amount = amount + 3;\n");
my $r = run_session($f, "e\na\na\ne\n", sub ($s, $view) {
unchanged($f);
if ($view == 1) {
is($s->{review}{count}, 0, 'initial mapping skips transaction');
path($f->{mapfile})->spew_utf8("default | Income:Updated\n");
path($s->{options}{corrections})->spew_utf8(
"UPDATE transactions SET amount = amount + 1; UPDATE transactions SET mapped_category = 'Income:Override', memo = 'one; two';\n");
}
}, '--beforemap', "$f->{dir}/before.sql", '--aftermap', "$f->{dir}/after.sql");
is($r->{error}, '', 'export succeeded');
is($r->{result}, 1, 'session committed');
like($r->{out}, qr/Apply and refresh successfully/, 'stale export refused');
my $dbh = connect_db($f->{db});
is($dbh->selectrow_arrayref('SELECT amount, mapped_category, exported, skipped FROM transactions'),
[106, 'Income:Override', 1, 0], 'fresh replay includes previously skipped row and one-off override');
$dbh->disconnect;
like(path($f->{output})->slurp_utf8, qr/T106\.00.*LIncome:Override/s, 'QIF matches reviewed data');
ok(!-e "$f->{db}.interactive.json", 'publication journal cleaned');
};
subtest 'failed SQL cannot publish partial changes and can be repaired' => sub {
my $f = fixture();
( run in 0.381 second using v1.01-cache-2.11-cpan-194103478d8 )