diff --git a/t/config_methods.t b/t/config_methods.t new file mode 100644 index 00000000..1abefde5 --- /dev/null +++ b/t/config_methods.t @@ -0,0 +1,129 @@ +#!/usr/bin/perl -w +# +# t/config_methods.t +# +# Unit tests for Template::Config methods: preload, load, constants, instdir +# + +use strict; +use lib qw( ./lib ../lib ); +use Test::More tests => 22; + +use Template::Config; + +my $factory = 'Template::Config'; + +#------------------------------------------------------------------------ +# load — successful module loading +#------------------------------------------------------------------------ + +{ + my $ok = $factory->load('Template::Stash'); + is($ok, 1, 'load() returns 1 for already-loaded module'); + + $ok = $factory->load('Template::Iterator'); + is($ok, 1, 'load() returns 1 for Template::Iterator'); + + $ok = $factory->load('Template::Exception'); + is($ok, 1, 'load() returns 1 for Template::Exception'); +} + +#------------------------------------------------------------------------ +# load — module not found +#------------------------------------------------------------------------ + +{ + my $ok = $factory->load('Template::Completely::Nonexistent::Module::XYZ'); + ok(!$ok, 'load() returns undef for nonexistent module'); + like($factory->error(), qr/failed to load/, 'error message mentions failed to load'); +} + +#------------------------------------------------------------------------ +# preload — loads all standard modules +#------------------------------------------------------------------------ + +{ + my $ok = $factory->preload(); + is($ok, 1, 'preload() returns 1 on success'); + + # verify standard modules are loaded + ok($INC{'Template/Context.pm'}, 'Template::Context is loaded after preload'); + ok($INC{'Template/Parser.pm'}, 'Template::Parser is loaded after preload'); + ok($INC{'Template/Provider.pm'}, 'Template::Provider is loaded after preload'); + ok($INC{'Template/Filters.pm'}, 'Template::Filters is loaded after preload'); +} + +#------------------------------------------------------------------------ +# preload — with extra modules +#------------------------------------------------------------------------ + +{ + my $ok = $factory->preload('Template::Document'); + is($ok, 1, 'preload() with extra module returns 1'); + ok($INC{'Template/Document.pm'}, 'extra module loaded after preload'); +} + +#------------------------------------------------------------------------ +# preload — fails on bad module +#------------------------------------------------------------------------ + +{ + my $ok = $factory->preload('Template::Does::Not::Exist::XYZ'); + ok(!$ok, 'preload() returns undef when extra module fails to load'); +} + +#------------------------------------------------------------------------ +# constants — creates a constants namespace +#------------------------------------------------------------------------ + +{ + my $constants = $factory->constants({ pi => 3.14159 }); + ok(defined $constants, 'constants() returns a defined value'); + ok(ref $constants, 'constants() returns a reference'); +} + +#------------------------------------------------------------------------ +# constants — usable in template +#------------------------------------------------------------------------ + +{ + use Template; + my $tt = Template->new({ + CONSTANTS => { greeting => 'Hello', target => 'World' }, + }); + + my $output = ''; + my $ok = $tt->process(\q{[% constants.greeting %] [% constants.target %]}, {}, \$output); + ok($ok, 'constants work in template processing'); + is($output, 'Hello World', 'constants produce correct output'); +} + +#------------------------------------------------------------------------ +# instdir — with $INSTDIR set +#------------------------------------------------------------------------ + +{ + local $Template::Config::INSTDIR = '/usr/local/tt2'; + + my $result = $factory->instdir(); + is($result, '/usr/local/tt2', 'instdir() returns base directory'); + + $result = $factory->instdir('templates'); + is($result, '/usr/local/tt2/templates', 'instdir() appends subdirectory'); + + # trailing slash handling + $Template::Config::INSTDIR = '/usr/local/tt2/'; + $result = $factory->instdir('lib'); + is($result, '/usr/local/tt2/lib', 'instdir() strips trailing slash'); +} + +#------------------------------------------------------------------------ +# instdir — without $INSTDIR set +#------------------------------------------------------------------------ + +{ + local $Template::Config::INSTDIR = ''; + my $result = $factory->instdir(); + ok(!$result, 'instdir() returns undef when INSTDIR not set'); + like($factory->error(), qr/no installation directory/, 'error mentions no installation directory'); +} diff --git a/t/context_methods.t b/t/context_methods.t new file mode 100644 index 00000000..17ae08fd --- /dev/null +++ b/t/context_methods.t @@ -0,0 +1,315 @@ +#!/usr/bin/perl -w +# +# t/context_methods.t +# +# Unit tests for Template::Context methods that lack direct coverage: +# define_filter, define_vmethod, define_block, define_view, +# localise/delocalise, visit/leave, reset, plugin, filter, debugging +# + +use strict; +use lib qw( ./lib ../lib ); +use Test::More tests => 45; +use Scalar::Util 'blessed'; + +use Template; +use Template::Context; +use Template::Config; +use Template::Stash; +use Template::Constants qw( :debug ); + +my $dir = -d 't' ? 't/test' : 'test'; + +#------------------------------------------------------------------------ +# localise / delocalise +#------------------------------------------------------------------------ + +{ + my $tt = Template->new({ + INCLUDE_PATH => "$dir/src:$dir/lib", + }); + my $context = $tt->service->context(); + my $stash = $context->stash(); + + $stash->set('animal', 'cat'); + is($stash->get('animal'), 'cat', 'variable set before localise'); + + # localise creates a cloned stash + $context->localise({ animal => 'dog' }); + my $cloned = $context->stash(); + isnt($cloned, $stash, 'localise creates a new stash'); + is($cloned->get('animal'), 'dog', 'localised variable has new value'); + + # delocalise reverts to parent stash + $context->delocalise(); + is($context->stash->get('animal'), 'cat', 'delocalise restores parent stash'); +} + +#------------------------------------------------------------------------ +# visit / leave +#------------------------------------------------------------------------ + +{ + my $context = Template::Config->context({ + INCLUDE_PATH => "$dir/src:$dir/lib", + }); + + my $blocks_a = { alpha => sub { 'block alpha' } }; + my $blocks_b = { beta => sub { 'block beta' } }; + + $context->visit(undef, $blocks_a); + is(scalar @{ $context->{ BLKSTACK } }, 1, 'visit pushes to BLKSTACK'); + + $context->visit(undef, $blocks_b); + is(scalar @{ $context->{ BLKSTACK } }, 2, 'second visit pushes another entry'); + + $context->leave(); + is(scalar @{ $context->{ BLKSTACK } }, 1, 'leave pops from BLKSTACK'); + + $context->leave(); + is(scalar @{ $context->{ BLKSTACK } }, 0, 'BLKSTACK empty after all leaves'); +} + +#------------------------------------------------------------------------ +# reset +#------------------------------------------------------------------------ + +{ + my $context = Template::Config->context({ + INCLUDE_PATH => "$dir/src:$dir/lib", + }); + + # add a block via visit + $context->visit(undef, { test_block => sub { 'hello' } }); + ok(scalar @{ $context->{ BLKSTACK } } > 0, 'BLKSTACK has entries before reset'); + + $context->reset(); + is(scalar @{ $context->{ BLKSTACK } }, 0, 'reset clears BLKSTACK'); + is(ref $context->{ BLOCKS }, 'HASH', 'BLOCKS is still a hash after reset'); +} + +#------------------------------------------------------------------------ +# define_block +#------------------------------------------------------------------------ + +{ + my $context = Template::Config->context({ + INCLUDE_PATH => "$dir/src:$dir/lib", + }); + + # define a block with a code reference + my $code = sub { return "hello from block" }; + my $result = $context->define_block('my_block', $code); + ok($result, 'define_block returns true for coderef'); + is($context->{ BLOCKS }->{ my_block }, $code, 'block stored in BLOCKS hash'); + + # define a block with text (gets compiled) + $result = $context->define_block('text_block', 'Hello [% name %]'); + ok($result, 'define_block returns true for text'); + ok(ref $context->{ BLOCKS }->{ text_block }, 'text block compiled to reference'); +} + +#------------------------------------------------------------------------ +# define_filter +#------------------------------------------------------------------------ + +{ + my $context = Template::Config->context({ + INCLUDE_PATH => "$dir/src:$dir/lib", + }); + + # define a static filter + my $upper_filter = sub { return uc $_[0] }; + my $ok = $context->define_filter('my_upper', $upper_filter); + is($ok, 1, 'define_filter returns 1 on success'); + + # verify it can be retrieved via filter() + my $filter = $context->filter('my_upper'); + ok(ref $filter eq 'CODE', 'filter() returns a coderef'); + is($filter->('hello'), 'HELLO', 'custom filter works correctly'); + + # define a dynamic filter (factory) + my $repeat_factory = sub { + my ($context, @args) = @_; + my $count = $args[0] || 2; + return sub { return $_[0] x $count }; + }; + $ok = $context->define_filter('my_repeat', $repeat_factory, 1); + is($ok, 1, 'define_filter returns 1 for dynamic filter'); +} + +#------------------------------------------------------------------------ +# define_vmethod +#------------------------------------------------------------------------ + +{ + my $context = Template::Config->context({ + INCLUDE_PATH => "$dir/src:$dir/lib", + }); + + # define a scalar vmethod via the context + $context->define_vmethod('scalar', 'my_reverse', sub { + return scalar reverse $_[0]; + }); + + # verify it works through template processing + my $tt = Template->new({ + INCLUDE_PATH => "$dir/src:$dir/lib", + }); + my $output = ''; + $tt->process(\qq{[% x = 'hello'; x.my_reverse %]}, {}, \$output); + is($output, 'olleh', 'custom scalar vmethod works via template'); +} + +#------------------------------------------------------------------------ +# filter() — caching behavior +#------------------------------------------------------------------------ + +{ + my $context = Template::Config->context({ + INCLUDE_PATH => "$dir/src:$dir/lib", + }); + + # fetch a built-in filter + my $html_filter = $context->filter('html'); + ok(ref $html_filter eq 'CODE', 'html filter is a coderef'); + is($html_filter->('test'), '<b>test</b>', 'html filter escapes correctly'); + + # fetch the same filter again (should come from cache) + my $html_filter2 = $context->filter('html'); + is($html_filter, $html_filter2, 'filter() returns cached filter on second call'); + + # fetch a filter with alias + my $uc_filter = $context->filter('upper', undef, 'my_alias'); + ok(ref $uc_filter eq 'CODE', 'upper filter with alias is a coderef'); + + # the alias should be cached + my $aliased = $context->filter('my_alias'); + is($aliased, $uc_filter, 'filter cached under alias'); +} + +#------------------------------------------------------------------------ +# filter() — error for non-existent filter +#------------------------------------------------------------------------ + +{ + my $context = Template::Config->context({ + INCLUDE_PATH => "$dir/src:$dir/lib", + }); + + my $filter = $context->filter('completely_nonexistent_filter'); + ok(!defined $filter, 'filter() returns undef for unknown filter'); + like($context->error(), qr/not found/, 'error message mentions not found'); +} + +#------------------------------------------------------------------------ +# plugin() — load a standard plugin +#------------------------------------------------------------------------ + +{ + my $context = Template::Config->context({ + INCLUDE_PATH => "$dir/src:$dir/lib", + }); + + my $plugin = eval { $context->plugin('Table', [[ 1, 2, 3, 4 ], { rows => 2 }]) }; + ok(defined $plugin, 'plugin() loads Table plugin'); + ok(ref $plugin, 'plugin returns an object'); +} + +#------------------------------------------------------------------------ +# plugin() — error for non-existent plugin +#------------------------------------------------------------------------ + +{ + my $context = Template::Config->context({ + INCLUDE_PATH => "$dir/src:$dir/lib", + }); + + eval { $context->plugin('Completely_Nonexistent_Plugin_XYZ', []) }; + ok($@, 'plugin() throws for non-existent plugin'); + like("$@", qr/plugin not found|not found|plugin/i, 'error mentions plugin issue'); +} + +#------------------------------------------------------------------------ +# debugging() +#------------------------------------------------------------------------ + +{ + my $context = Template::Config->context({ + INCLUDE_PATH => "$dir/src:$dir/lib", + DEBUG => DEBUG_DIRS, + }); + + ok($context->{ DEBUG_DIRS }, 'DEBUG_DIRS enabled initially'); + + # turn off debugging + $context->debugging('off'); + ok(!$context->{ DEBUG_DIRS }, 'debugging("off") disables DEBUG_DIRS'); + + # turn on debugging + $context->debugging('on'); + ok($context->{ DEBUG_DIRS }, 'debugging("on") enables DEBUG_DIRS'); + + # numeric on/off + $context->debugging('0'); + ok(!$context->{ DEBUG_DIRS }, 'debugging("0") disables DEBUG_DIRS'); + + $context->debugging('1'); + ok($context->{ DEBUG_DIRS }, 'debugging("1") enables DEBUG_DIRS'); + + # format + $context->debugging('format', ''); + is($context->{ DEBUG_FORMAT }, '', 'debugging sets custom format'); +} + +#------------------------------------------------------------------------ +# context process/include via template +#------------------------------------------------------------------------ + +{ + my $tt = Template->new({ + INCLUDE_PATH => "$dir/src:$dir/lib", + TRIM => 1, + }); + + # test process() through Template + my $output = ''; + my $ok = $tt->process(\q{[% BLOCK greet %]Hi [% name %][% END %][% PROCESS greet name='World' %]}, {}, \$output); + ok($ok, 'PROCESS directive works'); + is($output, 'Hi World', 'PROCESS output is correct'); + + # test include() through Template — INCLUDE localises stash + $output = ''; + $ok = $tt->process(\q{[% name = 'outer' %][% BLOCK inner %][% name = 'inner' %][% name %][% END %][% INCLUDE inner %] [% name %]}, {}, \$output); + ok($ok, 'INCLUDE directive works'); + is($output, 'inner outer', 'INCLUDE localises variable scope'); +} + +#------------------------------------------------------------------------ +# stash() accessor +#------------------------------------------------------------------------ + +{ + my $context = Template::Config->context({ + INCLUDE_PATH => "$dir/src:$dir/lib", + }); + + my $stash = $context->stash(); + ok(defined $stash, 'stash() returns a defined value'); + ok(blessed($stash), 'stash() returns a blessed object'); +} + +#------------------------------------------------------------------------ +# define_view +#------------------------------------------------------------------------ + +{ + my $tt = Template->new({ + INCLUDE_PATH => "$dir/src:$dir/lib", + }); + + my $output = ''; + my $ok = $tt->process(\q{[% VIEW my_view prefix='view/' %][% END %][% my_view.prefix %]}, {}, \$output); + ok($ok, 'VIEW directive works'); + is($output, 'view/', 'VIEW prefix accessible'); +} diff --git a/t/document_methods.t b/t/document_methods.t new file mode 100644 index 00000000..f48e026c --- /dev/null +++ b/t/document_methods.t @@ -0,0 +1,186 @@ +#!/usr/bin/perl -w +# +# t/document_methods.t +# +# Unit tests for Template::Document methods: as_perl, write_perl_file, +# variables, block, blocks, process, meta +# + +use strict; +use lib qw( ./lib ../lib ); +use Test::More tests => 28; + +use File::Temp qw( tempdir ); +use Template; +use Template::Document; +use Template::Config; + +my $dir = -d 't' ? 't/test' : 'test'; + +#------------------------------------------------------------------------ +# as_perl — generates valid Perl code +#------------------------------------------------------------------------ + +{ + my $content = { + BLOCK => q{sub { return "Hello World" }}, + DEFBLOCKS => {}, + METADATA => { name => 'test_template', modtime => '1234567890' }, + }; + + my $perl = Template::Document->as_perl($content); + ok(defined $perl, 'as_perl returns defined value'); + like($perl, qr/Template::Document->new/, 'as_perl contains constructor call'); + like($perl, qr/METADATA/, 'as_perl contains METADATA section'); + like($perl, qr/BLOCK/, 'as_perl contains BLOCK section'); + like($perl, qr/DEFBLOCKS/, 'as_perl contains DEFBLOCKS section'); + like($perl, qr/test_template/, 'as_perl contains template name in metadata'); + like($perl, qr/Compiled template generated by/, 'as_perl contains header comment'); +} + +#------------------------------------------------------------------------ +# as_perl — with DEFBLOCKS +#------------------------------------------------------------------------ + +{ + my $content = { + BLOCK => q{sub { return "main" }}, + DEFBLOCKS => { + header => q{sub { return "header" }}, + footer => q{sub { return "footer" }}, + }, + METADATA => { name => 'with_blocks' }, + }; + + my $perl = Template::Document->as_perl($content); + like($perl, qr/'header'/, 'as_perl includes header defblock'); + like($perl, qr/'footer'/, 'as_perl includes footer defblock'); +} + +#------------------------------------------------------------------------ +# as_perl — metadata with special characters +#------------------------------------------------------------------------ + +{ + my $content = { + BLOCK => q{sub { return "test" }}, + DEFBLOCKS => {}, + METADATA => { name => "it's a test", path => "path\\with\\backslash" }, + }; + + my $perl = Template::Document->as_perl($content); + like($perl, qr/it\\'s a test/, 'as_perl escapes single quotes in metadata'); + like($perl, qr/path\\\\with\\\\backslash/, 'as_perl escapes backslashes in metadata'); +} + +#------------------------------------------------------------------------ +# write_perl_file — writes to a file +#------------------------------------------------------------------------ + +{ + my $tmpdir = tempdir( CLEANUP => 1 ); + my $file = "$tmpdir/compiled.pl"; + + my $content = { + BLOCK => q{sub { return "hello from compiled" }}, + DEFBLOCKS => {}, + METADATA => { name => 'compiled_test' }, + }; + + my $ok = Template::Document->write_perl_file($file, $content); + ok($ok, 'write_perl_file returns true on success'); + ok(-f $file, 'compiled file exists'); + + # read and verify contents + open(my $fh, '<', $file) or die "Cannot open $file: $!"; + my $written = do { local $/; <$fh> }; + close($fh); + + like($written, qr/Template::Document->new/, 'written file contains constructor'); + like($written, qr/compiled_test/, 'written file contains template name'); +} + +#------------------------------------------------------------------------ +# write_perl_file — invalid filename +#------------------------------------------------------------------------ + +{ + my $ok = Template::Document->write_perl_file(undef, {}); + ok(!$ok, 'write_perl_file returns undef for undef filename'); + like(Template::Document->error(), qr/invalid filename/, 'error mentions invalid filename'); + + $ok = Template::Document->write_perl_file('', {}); + ok(!$ok, 'write_perl_file returns undef for empty filename'); +} + +#------------------------------------------------------------------------ +# block() / blocks() / variables() accessors +#------------------------------------------------------------------------ + +{ + my $block_sub = sub { return 'main content' }; + my $defblocks = { sidebar => sub { return 'sidebar' } }; + my $vars_hash = { foo => 1, bar => 1 }; + + my $doc = Template::Document->new({ + BLOCK => $block_sub, + DEFBLOCKS => $defblocks, + VARIABLES => $vars_hash, + METADATA => { name => 'accessor_test' }, + }); + ok(defined $doc, 'Document created successfully'); + + is($doc->block(), $block_sub, 'block() returns the BLOCK coderef'); + is($doc->blocks(), $defblocks, 'blocks() returns DEFBLOCKS hashref'); + is($doc->variables(), $vars_hash, 'variables() returns VARIABLES hashref'); +} + +#------------------------------------------------------------------------ +# meta() accessor +#------------------------------------------------------------------------ + +{ + my $doc = Template::Document->new({ + BLOCK => sub { return '' }, + METADATA => { name => 'meta_test', author => 'tester' }, + }); + + # meta() returns a filtered hash ref; name/modtime are excluded + my $meta = $doc->meta(); + is(ref $meta, 'HASH', 'meta() returns a hash reference'); + + # metadata fields accessible via AUTOLOAD + is($doc->name, 'meta_test', 'AUTOLOAD accessor for name works'); +} + +#------------------------------------------------------------------------ +# process via full template pipeline +#------------------------------------------------------------------------ + +{ + my $tt = Template->new({ + INCLUDE_PATH => "$dir/src:$dir/lib", + TRIM => 1, + }); + + my $output = ''; + my $ok = $tt->process(\q{[% BLOCK greeting %]Hello [% who %][% END %][% INCLUDE greeting who='World' %]}, {}, \$output); + ok($ok, 'Document process works through Template pipeline'); + is($output, 'Hello World', 'Document process produces correct output'); +} + +#------------------------------------------------------------------------ +# process — recursion detection via _HOT flag +#------------------------------------------------------------------------ + +{ + my $doc = Template::Document->new({ + BLOCK => sub { return 'content' }, + METADATA => { name => 'hot_test' }, + }); + + # Simulate recursion: set _HOT flag and verify process detects it + ok(!$doc->{ _HOT }, '_HOT is false initially'); + $doc->{ _HOT } = 1; + ok($doc->{ _HOT }, '_HOT can be set to simulate in-progress processing'); +} diff --git a/t/stash_clone.t b/t/stash_clone.t new file mode 100644 index 00000000..0a1c422b --- /dev/null +++ b/t/stash_clone.t @@ -0,0 +1,265 @@ +#!/usr/bin/perl -w +# +# t/stash_clone.t +# +# Unit tests for Template::Stash clone/declone, define_vmethod, undefined +# + +use strict; +use lib qw( ./lib ../lib ); +use Test::More tests => 32; + +use Template; +use Template::Stash; +use Template::Config; + +#------------------------------------------------------------------------ +# clone — basic behavior +#------------------------------------------------------------------------ + +{ + my $stash = Template::Stash->new({ + name => 'Alice', + age => 30, + }); + + my $clone = $stash->clone({ name => 'Bob' }); + ok(defined $clone, 'clone() returns a defined value'); + isa_ok($clone, 'Template::Stash', 'clone is a Stash object'); + + is($clone->get('name'), 'Bob', 'cloned stash has overridden value'); + is($clone->get('age'), 30, 'cloned stash inherits parent value'); + + # parent stash unchanged + is($stash->get('name'), 'Alice', 'parent stash unchanged after clone'); +} + +#------------------------------------------------------------------------ +# clone — with empty params +#------------------------------------------------------------------------ + +{ + my $stash = Template::Stash->new({ x => 42 }); + my $clone = $stash->clone(); + is($clone->get('x'), 42, 'clone with no params inherits all values'); +} + +#------------------------------------------------------------------------ +# clone — import parameter +#------------------------------------------------------------------------ + +{ + my $stash = Template::Stash->new({ a => 1 }); + my $import_hash = { b => 2, c => 3 }; + my $clone = $stash->clone({ import => $import_hash }); + + is($clone->get('a'), 1, 'clone with import inherits parent values'); + is($clone->get('b'), 2, 'clone imported value b'); + is($clone->get('c'), 3, 'clone imported value c'); +} + +#------------------------------------------------------------------------ +# clone — import non-hash is ignored +#------------------------------------------------------------------------ + +{ + my $stash = Template::Stash->new({ a => 1 }); + my $clone = $stash->clone({ import => 'not_a_hash' }); + is($clone->get('a'), 1, 'clone with non-hash import still works'); +} + +#------------------------------------------------------------------------ +# declone — returns parent +#------------------------------------------------------------------------ + +{ + my $stash = Template::Stash->new({ name => 'root' }); + my $clone = $stash->clone({ name => 'child' }); + + my $parent = $clone->declone(); + is($parent, $stash, 'declone returns the parent stash'); + is($parent->get('name'), 'root', 'parent stash has original value'); +} + +#------------------------------------------------------------------------ +# declone — on root stash returns self +#------------------------------------------------------------------------ + +{ + my $stash = Template::Stash->new({ name => 'root' }); + my $result = $stash->declone(); + is($result, $stash, 'declone on root stash returns self'); +} + +#------------------------------------------------------------------------ +# nested clone/declone +#------------------------------------------------------------------------ + +{ + my $root = Template::Stash->new({ level => 'root' }); + my $child = $root->clone({ level => 'child' }); + my $grandchild = $child->clone({ level => 'grandchild' }); + + is($grandchild->get('level'), 'grandchild', 'grandchild has its own value'); + + my $back_to_child = $grandchild->declone(); + is($back_to_child->get('level'), 'child', 'declone returns to child'); + + my $back_to_root = $back_to_child->declone(); + is($back_to_root->get('level'), 'root', 'double declone returns to root'); +} + +#------------------------------------------------------------------------ +# modifications in clone don't affect parent +#------------------------------------------------------------------------ + +{ + my $stash = Template::Stash->new({ color => 'red' }); + my $clone = $stash->clone(); + + $clone->set('color', 'blue'); + is($clone->get('color'), 'blue', 'clone has modified value'); + is($stash->get('color'), 'red', 'parent unaffected by clone modification'); +} + +#------------------------------------------------------------------------ +# define_vmethod — scalar +#------------------------------------------------------------------------ + +{ + Template::Stash->define_vmethod('scalar', 'test_double', sub { + return $_[0] . $_[0]; + }); + + my $stash = Template::Stash->new({}); + # verify it's accessible via template processing + my $tt = Template->new(); + my $output = ''; + $tt->process(\q{[% x = 'ab'; x.test_double %]}, {}, \$output); + is($output, 'abab', 'custom scalar vmethod works'); +} + +#------------------------------------------------------------------------ +# define_vmethod — hash +#------------------------------------------------------------------------ + +{ + Template::Stash->define_vmethod('hash', 'test_key_count', sub { + return scalar keys %{$_[0]}; + }); + + my $tt = Template->new(); + my $output = ''; + $tt->process(\q{[% h.test_key_count %]}, { h => { a => 1, b => 2 } }, \$output); + is($output, '2', 'custom hash vmethod works'); +} + +#------------------------------------------------------------------------ +# define_vmethod — list +#------------------------------------------------------------------------ + +{ + Template::Stash->define_vmethod('list', 'test_sum', sub { + my $sum = 0; + $sum += $_ for @{$_[0]}; + return $sum; + }); + + my $tt = Template->new(); + my $output = ''; + $tt->process(\q{[% nums.test_sum %]}, { nums => [1, 2, 3, 4] }, \$output); + is($output, '10', 'custom list vmethod works'); +} + +#------------------------------------------------------------------------ +# define_vmethod — 'item' alias for 'scalar' +#------------------------------------------------------------------------ + +{ + Template::Stash->define_vmethod('item', 'test_item_vmethod', sub { + return "item: $_[0]"; + }); + + my $tt = Template->new(); + my $output = ''; + $tt->process(\q{[% x = 'foo'; x.test_item_vmethod %]}, {}, \$output); + is($output, 'item: foo', 'item type alias for scalar works'); +} + +#------------------------------------------------------------------------ +# define_vmethod — 'array' alias for 'list' +#------------------------------------------------------------------------ + +{ + Template::Stash->define_vmethod('array', 'test_array_len', sub { + return scalar @{$_[0]}; + }); + + my $tt = Template->new(); + my $output = ''; + $tt->process(\q{[% items.test_array_len %]}, { items => [qw(a b c)] }, \$output); + is($output, '3', 'array type alias for list works'); +} + +#------------------------------------------------------------------------ +# define_vmethod — invalid type dies +#------------------------------------------------------------------------ + +{ + eval { Template::Stash->define_vmethod('invalid_type', 'foo', sub { }) }; + like($@, qr/invalid vmethod type/i, 'define_vmethod dies on invalid type'); +} + +#------------------------------------------------------------------------ +# undefined — non-strict mode returns empty string +#------------------------------------------------------------------------ + +{ + my $stash = Template::Stash->new({}); + my $result = $stash->undefined('nonexistent', []); + is($result, '', 'undefined returns empty string in non-strict mode'); +} + +#------------------------------------------------------------------------ +# undefined — strict mode throws +#------------------------------------------------------------------------ + +{ + my $stash = Template::Stash->new({ _STRICT => 1 }); + eval { $stash->undefined('missing_var', []) }; + ok($@, 'undefined throws in strict mode'); + like("$@", qr/undefined variable/i, 'error mentions undefined variable'); +} + +#------------------------------------------------------------------------ +# get with STRICT mode +#------------------------------------------------------------------------ + +{ + my $tt = Template->new({ STRICT => 1 }); + my $output = ''; + my $ok = $tt->process(\q{[% TRY %][% no_such_var %][% CATCH %]caught: [% error.info %][% END %]}, {}, \$output); + ok($ok, 'STRICT mode template processes with TRY/CATCH'); + like($output, qr/undefined variable.*no_such_var/i, 'STRICT mode catches undefined variable access'); +} + +#------------------------------------------------------------------------ +# set and get with compound variable +#------------------------------------------------------------------------ + +{ + my $stash = Template::Stash->new({}); + $stash->set('user.name', 'Alice'); + is($stash->get('user.name'), 'Alice', 'compound variable set/get works'); +} + +#------------------------------------------------------------------------ +# update method +#------------------------------------------------------------------------ + +{ + my $stash = Template::Stash->new({ a => 1 }); + $stash->update({ a => 10, b => 20 }); + is($stash->get('a'), 10, 'update overwrites existing value'); + is($stash->get('b'), 20, 'update adds new value'); +}