From da65744c6bf6264c1552b0bf38191534c5917244 Mon Sep 17 00:00:00 2001 From: Chris Nighswonger Date: Tue, 27 Jan 2026 00:34:19 +0000 Subject: [PATCH] Bug 41719: Add rendering test coverage for Labels and Patroncards Extends t/db_dependent/Labels/t_Label.t with subtests for: - draw_label_text() structure validation - draw_guide_box() PDF stream output - All supported barcode types (CODE39, CODE39MOD, CODE39MOD10, COOP2OF5, INDUSTRIAL2OF5, EAN13) - create_label() printing type orchestration (BIB/BAR/BIBBAR/BARBIB) Creates t/db_dependent/Patroncards/t_Patroncard.t with subtests for: - draw_guide_box(), draw_guide_grid(), draw_text() - draw_barcode(), draw_image() - End-to-end PDF output AI Assistance: Claude Opus 4.5 drafted test code and debugged a PDF::Reuse issue (GitHub cnighswonger/PDF-Reuse#24, fixed in 0.43). Human author directed the approach, rejected initial implementation that merely exercised PDF::Reuse rather than Koha code, reviewed all changes, and verified tests pass. Assisted-by: Claude Opus 4.5 (Anthropic) --- t/db_dependent/Labels/t_Label.t | 176 +++++++++- t/db_dependent/Patroncards/t_Patroncard.t | 387 ++++++++++++++++++++++ 2 files changed, 558 insertions(+), 5 deletions(-) create mode 100644 t/db_dependent/Patroncards/t_Patroncard.t diff --git a/t/db_dependent/Labels/t_Label.t b/t/db_dependent/Labels/t_Label.t index 9eb967a2bb..d0a13a649e 100755 --- a/t/db_dependent/Labels/t_Label.t +++ b/t/db_dependent/Labels/t_Label.t @@ -2,7 +2,7 @@ # This file is part of Koha. # -# Copyright 2020 Koha Development team +# Copyright 2020, 2026 Koha Development team # Copyright (C) 2017 Mark Tompsett # # Koha is free software; you can redistribute it and/or modify it @@ -21,25 +21,26 @@ use Modern::Perl; use Test::NoWarnings; -use Test::More tests => 7; +use Test::More tests => 11; use t::lib::TestBuilder; use t::lib::Mocks; use MARC::Record; use MARC::Field; use Data::Dumper; +use File::Temp qw( tempfile ); use C4::Items; use C4::Biblio; use C4::Labels::Layout; +use C4::Creators::PDF; use Koha::Database; use_ok('C4::Labels::Label'); -my $database = Koha::Database->new(); -my $schema = $database->schema(); -$schema->storage->txn_begin(); +my $schema = Koha::Database->new->schema; +$schema->storage->txn_begin; my $batch_id; my ( $llx, $lly ) = ( 0, 0 ); @@ -152,6 +153,171 @@ is_deeply( is( $barcode_width, '2.104', ); is( $barcode_height, '0.01', ); +# ======================================== +# Subtest: draw_label_text() deep assertions +# ======================================== +subtest 'draw_label_text() returns correct text structure' => sub { + plan tests => 8; + + my $label_bib = C4::Labels::Label->new( + %$label_info, + format_string => $format_string, + printing_type => 'BIB', + justify => 'L', + ); + my $text_result = $label_bib->create_label(); + + ok( defined $text_result, 'create_label(BIB) returns defined value' ); + is( ref $text_result, 'ARRAY', 'Return value is an arrayref' ); + +SKIP: { + skip 'No label text returned', 6 unless ref $text_result eq 'ARRAY' && @$text_result; + + my $first_line = $text_result->[0]; + ok( exists $first_line->{'text_llx'}, 'First line has text_llx key' ); + ok( exists $first_line->{'text_lly'}, 'First line has text_lly key' ); + ok( exists $first_line->{'font'}, 'First line has font key' ); + ok( exists $first_line->{'font_size'}, 'First line has font_size key' ); + ok( exists $first_line->{'line'}, 'First line has line key' ); + + if ( scalar @$text_result > 1 ) { + cmp_ok( + $text_result->[1]{'text_lly'}, '<', $first_line->{'text_lly'}, + 'Second line text_lly is less than first (text flows downward)' + ); + } else { + pass('Only one text line returned - skip lly ordering check'); + } + } +}; + +# ======================================== +# Subtest: draw_guide_box() +# ======================================== +subtest 'draw_guide_box() returns PDF stream when enabled' => sub { + plan tests => 3; + + my $label_with_box = C4::Labels::Label->new( + %$label_info, + format_string => $format_string, + guidebox => 1, + llx => 10, + lly => 20, + width => 100, + height => 50, + ); + my $box_stream = $label_with_box->draw_guide_box(); + ok( $box_stream, 'draw_guide_box returns truthy when guidebox enabled' ); + like( $box_stream, qr/10 20 100 50 re/, 'PDF stream contains expected rectangle coordinates' ); + + my $label_no_box = C4::Labels::Label->new( + %$label_info, + format_string => $format_string, + guidebox => 0, + ); + ok( !$label_no_box->draw_guide_box(), 'draw_guide_box returns falsy when guidebox disabled' ); +}; + +# ======================================== +# Subtest: barcode() with all supported types +# ======================================== +subtest 'barcode() generates all supported barcode types' => sub { + plan tests => 7; + + my $pdf = C4::Creators::PDF->new( InitVars => 1 ); + $pdf->Page(); + + my @barcode_tests = ( + { type => 'CODE39', barcode => 'TEST97531', desc => 'CODE39' }, + { type => 'CODE39MOD', barcode => 'TEST97531', desc => 'CODE39MOD (modulo43 checksum)' }, + { type => 'CODE39MOD10', barcode => 'TEST97531', desc => 'CODE39MOD10 (modulo10 checksum)' }, + { type => 'COOP2OF5', barcode => '97531', desc => 'COOP2OF5 (numeric)' }, + { type => 'INDUSTRIAL2OF5', barcode => '97531', desc => 'INDUSTRIAL2OF5 (numeric)' }, + { type => 'EAN13', barcode => '5901234123457', desc => 'EAN13 (13-digit)' }, + ); + + for my $bc_test (@barcode_tests) { + my $bc_label = C4::Labels::Label->new( + %$label_info, + format_string => $format_string, + printing_type => 'BAR', + barcode_type => $bc_test->{type}, + barcode => $bc_test->{barcode}, + ); + my $result = eval { $bc_label->create_label(); 1; }; + ok( $result, "barcode() $bc_test->{desc} generates without error" ) or diag($@); + } + + # Verify the PDF output has content + my ( $out_fh, $out_file ) = tempfile( SUFFIX => '.pdf', UNLINK => 1 ); + open( $out_fh, '>', $out_file ); + select $out_fh; + eval { $pdf->End(); }; + select STDOUT; + close $out_fh; + ok( -s $out_file, 'PDF with barcodes has content' ); +}; + +# ======================================== +# Subtest: create_label() printing type orchestration +# ======================================== +subtest 'create_label() printing type orchestration' => sub { + plan tests => 4; + + my $pdf = C4::Creators::PDF->new( InitVars => 1 ); + $pdf->Page(); + + # BIB - returns label_text (text only) + my $label_bib = C4::Labels::Label->new( + %$label_info, + format_string => $format_string, + printing_type => 'BIB', + ); + my $bib_result = $label_bib->create_label(); + ok( defined $bib_result, 'BIB printing type returns label text' ); + + # BAR - returns undef (barcode only) + my $label_bar = C4::Labels::Label->new( + %$label_info, + format_string => $format_string, + printing_type => 'BAR', + barcode_type => 'CODE39', + barcode => 'ORCHTEST1', + ); + my $bar_result = $label_bar->create_label(); + ok( !defined $bar_result, 'BAR printing type returns undef (barcode only)' ); + + # BIBBAR - returns label_text (both text and barcode) + my $label_bibbar = C4::Labels::Label->new( + %$label_info, + format_string => $format_string, + printing_type => 'BIBBAR', + barcode_type => 'CODE39', + barcode => 'ORCHTEST2', + ); + my $bibbar_result = $label_bibbar->create_label(); + ok( defined $bibbar_result, 'BIBBAR printing type returns label text' ); + + # BARBIB - returns label_text (both text and barcode) + my $label_barbib = C4::Labels::Label->new( + %$label_info, + format_string => $format_string, + printing_type => 'BARBIB', + barcode_type => 'CODE39', + barcode => 'ORCHTEST3', + ); + my $barbib_result = $label_barbib->create_label(); + ok( defined $barbib_result, 'BARBIB printing type returns label text' ); + + # Clean up PDF session + my ( $out_fh, $out_file ) = tempfile( SUFFIX => '.pdf', UNLINK => 1 ); + open( $out_fh, '>', $out_file ); + select $out_fh; + eval { $pdf->End(); }; + select STDOUT; + close $out_fh; +}; + $schema->storage->txn_rollback(); 1; diff --git a/t/db_dependent/Patroncards/t_Patroncard.t b/t/db_dependent/Patroncards/t_Patroncard.t new file mode 100644 index 0000000000..5f2841884b --- /dev/null +++ b/t/db_dependent/Patroncards/t_Patroncard.t @@ -0,0 +1,387 @@ +#!/usr/bin/perl + +# This file is part of Koha. +# +# Copyright 2026 Koha Development team +# +# 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 Test::NoWarnings; +use Test::More tests => 8; +use Test::Warn; +use t::lib::TestBuilder; +use t::lib::Mocks; + +use File::Temp qw( tempfile ); + +use C4::Creators::PDF; +use Koha::Database; + +use_ok('C4::Patroncards::Patroncard'); + +my $schema = Koha::Database->new->schema; +$schema->storage->txn_begin; + +my $builder = t::lib::TestBuilder->new; + +# Build test data +my $branch = $builder->build( { source => 'Branch' } ); +my $category = $builder->build( { source => 'Category' } ); +my $patron = $builder->build_object( + { + class => 'Koha::Patrons', + value => { + firstname => 'Testfirst', + surname => 'Testsurname', + cardnumber => 'CARD12345', + branchcode => $branch->{branchcode}, + categorycode => $category->{categorycode}, + } + } +); + +t::lib::Mocks::mock_userenv( { branchcode => $branch->{branchcode} } ); + +# ---------------------------------------- +# Helper: build a Patroncard with given layout +# ---------------------------------------- +sub build_patroncard { + my (%overrides) = @_; + my $layout = $overrides{layout} || { + units => 'POINT', + text => [ + ' ', + { llx => 10, lly => 80, font => 'TR', font_size => 10, text_alignment => 'L' }, + '', + { llx => 10, lly => 60, font => 'TR', font_size => 8, text_alignment => 'C' }, + ], + barcode => [ + { + type => $overrides{barcode_type} || 'CODE39', + data => $overrides{barcode_data} || 'CARD12345', + llx => 10, + lly => 10, + height_scale => 0.01, + width_scale => 0.8, + text_print => '', + } + ], + images => $overrides{images} || {}, + }; + return C4::Patroncards::Patroncard->new( + batch_id => 1, + borrower_number => $patron->borrowernumber, + llx => 36, + lly => 36, + height => 200, + width => 300, + layout => $layout, + text_wrap_cols => 40, + ); +} + +# ======================================== +# Subtest: draw_guide_box() +# ======================================== +subtest 'draw_guide_box()' => sub { + plan tests => 3; + + my $pdf = C4::Creators::PDF->new( InitVars => 0 ); + $pdf->Page(); + + my $card = build_patroncard(); + my $result = eval { $card->draw_guide_box($pdf); 1; }; + ok( $result, 'draw_guide_box($pdf) succeeds without error' ) or diag($@); + + # Without $pdf argument should return -1 + my $bad_result; + warning_like { $bad_result = $card->draw_guide_box(); } + qr/No pdf object passed in/, + 'draw_guide_box() without $pdf warns appropriately'; + is( $bad_result, -1, 'draw_guide_box() without $pdf returns -1' ); + + # Clean up PDF session + my ( $out_fh, $out_file ) = tempfile( SUFFIX => '.pdf', UNLINK => 1 ); + open( $out_fh, '>', $out_file ); + select $out_fh; + eval { $pdf->End(); }; + select STDOUT; + close $out_fh; +}; + +# ======================================== +# Subtest: draw_guide_grid() +# ======================================== +subtest 'draw_guide_grid()' => sub { + plan tests => 4; + + my $pdf = C4::Creators::PDF->new( InitVars => 0 ); + $pdf->Page(); + + # Test with POINT units + # Bug 41718: draw_guide_grid() in Patroncard.pm uses hardcoded "Courier" font + # name and calls StrWidth without font/size args, producing warnings + my $card_point = build_patroncard(); + my $result; + { + local $SIG{__WARN__} = sub { }; + $result = eval { $card_point->draw_guide_grid($pdf); 1; }; + } + ok( $result, 'draw_guide_grid() with POINT units succeeds' ) or diag($@); + + # Test with MM units + my $card_mm = build_patroncard( + layout => { + units => 'MM', + text => [], + barcode => [ + { + type => 'CODE39', data => 'X', llx => 0, lly => 0, height_scale => 0.01, width_scale => 0.8, + text_print => '' + } + ], + images => {}, + } + ); + { + local $SIG{__WARN__} = sub { }; + $result = eval { $card_mm->draw_guide_grid($pdf); 1; }; + } + ok( $result, 'draw_guide_grid() with MM units succeeds' ) or diag($@); + + # Without $pdf argument + my $bad_result; + warning_like { $bad_result = $card_point->draw_guide_grid(); } + qr/No pdf object passed in/, + 'draw_guide_grid() without $pdf warns appropriately'; + is( $bad_result, -1, 'draw_guide_grid() without $pdf returns -1' ); + + # Clean up PDF session + my ( $out_fh, $out_file ) = tempfile( SUFFIX => '.pdf', UNLINK => 1 ); + open( $out_fh, '>', $out_file ); + select $out_fh; + eval { $pdf->End(); }; + select STDOUT; + close $out_fh; +}; + +# ======================================== +# Subtest: draw_text() +# ======================================== +subtest 'draw_text()' => sub { + plan tests => 4; + + my $pdf = C4::Creators::PDF->new( InitVars => 0 ); + $pdf->Page(); + + my $card = build_patroncard(); + my $result = eval { $card->draw_text($pdf); 1; }; + ok( $result, 'draw_text($pdf) succeeds without error' ) or diag($@); + + # Without $pdf argument + my $bad_result; + warning_like { $bad_result = $card->draw_text(); } + qr/No pdf object passed in/, + 'draw_text() without $pdf warns appropriately'; + is( $bad_result, -1, 'draw_text() without $pdf returns -1' ); + + # With empty text layout - should return undef gracefully + my $card_no_text = build_patroncard( + layout => { + units => 'POINT', + text => 'not_an_array', + barcode => [ + { + type => 'CODE39', data => 'X', llx => 0, lly => 0, height_scale => 0.01, width_scale => 0.8, + text_print => '' + } + ], + images => {}, + } + ); + my $no_text_result = eval { $card_no_text->draw_text($pdf); 1; }; + ok( $no_text_result, 'draw_text() with non-ARRAY text layout returns gracefully' ) or diag($@); + + # Clean up PDF session + my ( $out_fh, $out_file ) = tempfile( SUFFIX => '.pdf', UNLINK => 1 ); + open( $out_fh, '>', $out_file ); + select $out_fh; + eval { $pdf->End(); }; + select STDOUT; + close $out_fh; +}; + +# ======================================== +# Subtest: draw_barcode() +# ======================================== +subtest 'draw_barcode()' => sub { + plan tests => 3; + + my $pdf = C4::Creators::PDF->new( InitVars => 0 ); + $pdf->Page(); + + # CODE39 + my $card_c39 = build_patroncard( barcode_type => 'CODE39', barcode_data => 'CARD12345' ); + my $result = eval { $card_c39->draw_barcode($pdf); 1; }; + ok( $result, 'draw_barcode() CODE39 succeeds' ) or diag($@); + + # COOP2OF5 + my $card_coop = build_patroncard( barcode_type => 'COOP2OF5', barcode_data => '12345' ); + $result = eval { $card_coop->draw_barcode($pdf); 1; }; + ok( $result, 'draw_barcode() COOP2OF5 succeeds' ) or diag($@); + + # INDUSTRIAL2OF5 + my $card_ind = build_patroncard( barcode_type => 'INDUSTRIAL2OF5', barcode_data => '12345' ); + $result = eval { $card_ind->draw_barcode($pdf); 1; }; + ok( $result, 'draw_barcode() INDUSTRIAL2OF5 succeeds' ) or diag($@); + + # Clean up PDF session + my ( $out_fh, $out_file ) = tempfile( SUFFIX => '.pdf', UNLINK => 1 ); + open( $out_fh, '>', $out_file ); + select $out_fh; + eval { $pdf->End(); }; + select STDOUT; + close $out_fh; +}; + +# ======================================== +# Subtest: draw_image() +# ======================================== +subtest 'draw_image()' => sub { + plan tests => 3; + + my $pdf = C4::Creators::PDF->new( InitVars => 0 ); + $pdf->Page(); + + # Create a minimal JPEG using GD + my $jpeg_data; + eval { require GD; }; + if ($@) { + + # Fallback: minimal JPEG binary + $jpeg_data = pack( + "H*", + "ffd8ffe000104a46494600010100000100010000" + . "ffdb004300080606070605080707070909080a0c14" + . "0d0c0b0b0c1912130f141d1a1f1e1d1a1c1c2024" + . "2e2720222c231c1c2837292c30313434341f27393d" + . "38323c2e333432ffc0000b08000100010101011100" + . "ffc4001f000001050101010101010000000000000000" + . "0102030405060708090a0bffda00080101000003f1" + . "00fb52800001ffd9" + ); + } else { + my $im = GD::Image->new( 4, 4, 1 ); + my $white = $im->colorAllocate( 255, 255, 255 ); + my $red = $im->colorAllocate( 255, 0, 0 ); + $im->filledRectangle( 0, 0, 3, 3, $white ); + $im->setPixel( 1, 1, $red ); + $jpeg_data = $im->jpeg(100); + } + + my $images = { + img1 => { + data_source => [ { image_source => 'db' } ], + data => $jpeg_data, + Tx => 5, + Ty => 5, + Sx => 50, + Sy => 50, + Ox => 0, + Oy => 0, + scale => 1, + alt => { data => $jpeg_data, Sx => 50, Sy => 50 }, + }, + }; + + my $card_img = build_patroncard( images => $images ); + my $result; + { + local $SIG{__WARN__} = sub { }; # suppress possible JPEG color warnings + $result = eval { $card_img->draw_image($pdf); 1; }; + } + ok( $result, 'draw_image() with JPEG data succeeds' ) or diag($@); + + # Test with image_source => 'none' (should skip) + my $images_none = { + img1 => { + data_source => [ { image_source => 'none' } ], + data => $jpeg_data, + Tx => 5, + Ty => 5, + Sx => 50, + Sy => 50, + Ox => 0, + Oy => 0, + scale => 1, + alt => { data => $jpeg_data, Sx => 50, Sy => 50 }, + }, + }; + my $card_no_img = build_patroncard( images => $images_none ); + $result = eval { $card_no_img->draw_image($pdf); 1; }; + ok( $result, 'draw_image() with image_source=none skips gracefully' ) or diag($@); + + # Without $pdf argument + my $bad_result; + warning_like { $bad_result = $card_img->draw_image(); } + qr/No pdf object passed in/, + 'draw_image() without $pdf warns appropriately'; + + # Clean up PDF session + my ( $out_fh, $out_file ) = tempfile( SUFFIX => '.pdf', UNLINK => 1 ); + open( $out_fh, '>', $out_file ); + select $out_fh; + eval { $pdf->End(); }; + select STDOUT; + close $out_fh; +}; + +# ======================================== +# Subtest: End-to-end PDF output +# ======================================== +subtest 'End-to-end PDF output' => sub { + plan tests => 2; + + my $pdf = C4::Creators::PDF->new( InitVars => 0 ); + $pdf->Page(); + + my $card = build_patroncard(); + eval { + $card->draw_guide_box($pdf); + $card->draw_text($pdf); + $card->draw_barcode($pdf); + }; + + my ( $out_fh, $out_file ) = tempfile( SUFFIX => '.pdf', UNLINK => 1 ); + open( $out_fh, '>', $out_file ); + select $out_fh; + eval { $pdf->End(); }; + select STDOUT; + close $out_fh; + + ok( -s $out_file, 'End-to-end PDF file has content' ); + + # Verify PDF header + open( my $check_fh, '<', $out_file ) or die "Cannot open $out_file: $!"; + my $header; + read( $check_fh, $header, 5 ); + close $check_fh; + is( $header, '%PDF-', 'Output file has valid PDF header' ); +}; + +$schema->storage->txn_rollback(); + +1; -- 2.43.0