16625d0a458c5b7b2d206116c36b52651cfb615a
[irspy-moved-to-github.git] / lib / ZOOM / IRSpy.pm
1 # $Id: IRSpy.pm,v 1.90 2008-07-16 11:42:13 mike Exp $
2
3 package ZOOM::IRSpy;
4
5 use 5.008;
6 use strict;
7 use warnings;
8
9 use Data::Dumper;               # For debugging only
10 use File::Basename;
11 use XML::LibXSLT;
12 use XML::LibXML;
13 use XML::LibXML::XPathContext;
14 use ZOOM;
15 use Net::Z3950::ZOOM 1.13;      # For the ZOOM version-check only
16 use ZOOM::IRSpy::Node;
17 use ZOOM::IRSpy::Connection;
18 use ZOOM::IRSpy::Stats;
19 use ZOOM::IRSpy::Utils qw(cql_target render_record
20                           irspy_xpath_context irspy_make_identifier
21                           irspy_record2identifier);
22
23 our @ISA = qw();
24 our $VERSION = '1.02';
25 our $irspy_to_zeerex_xsl = dirname(__FILE__) . '/../../xsl/irspy2zeerex.xsl';
26 our $debug = 0;
27 our $xslt_max_depth = 250;
28
29
30 # Enumeration for callback functions to return
31 package ZOOM::IRSpy::Status;
32 sub OK { 29 }                   # No problems, task is still progressing
33 sub TASK_DONE { 18 }            # Task is complete, next task should begin
34 sub TEST_GOOD { 8 }             # Whole test is complete, and succeeded
35 sub TEST_BAD { 31 }             # Whole test is complete, and failed
36 sub TEST_SKIPPED { 12 }         # Test couldn't be run
37 package ZOOM::IRSpy;
38
39
40 =head1 NAME
41
42 ZOOM::IRSpy - Perl extension for discovering and analysing IR services
43
44 =head1 SYNOPSIS
45
46  use ZOOM::IRSpy;
47  $spy = new ZOOM::IRSpy("target/string/for/irspy/database");
48  $spy->targets(@targets);
49  $spy->initialise("Main");
50  $res = $spy->check();
51
52 =head1 DESCRIPTION
53
54 This module exists to implement the IRspy program, which discovers,
55 analyses and monitors IR servers implementing the Z39.50 and SRU/W
56 protocols.  It is a successor to the ZSpy program.
57
58 =cut
59
60 BEGIN {
61     ZOOM::Log::mask_str("irspy");
62     ZOOM::Log::mask_str("irspy_debug");
63     ZOOM::Log::mask_str("irspy_event");
64     ZOOM::Log::mask_str("irspy_unhandled");
65     ZOOM::Log::mask_str("irspy_test");
66     ZOOM::Log::mask_str("irspy_task");
67 }
68
69 sub new {
70     my $class = shift();
71     my($dbname, $user, $password, $activeSetSize) = @_;
72
73
74     my @options;
75     push @options, (user => $user, password => $password)
76         if defined $user;
77
78     my $conn = new ZOOM::Connection($dbname, 0, @options)
79         or die "$0: can't connection to IRSpy database 'dbname'";
80
81     my $xslt = new XML::LibXSLT;
82
83     # raise the maximum number of nested template calls and variables/params (default 250)
84     warn "raise the maximum number of nested template calls: $xslt_max_depth\n" if $debug;
85     $xslt->max_depth($xslt_max_depth);
86
87     $xslt->register_function($ZOOM::IRSpy::Utils::IRSPY_NS, 'strcmp',
88                              \&ZOOM::IRSpy::Utils::xslt_strcmp);
89
90     my $libxml = new XML::LibXML;
91     warn "use irspy_to_zeerex_xsl xslt sheet: $irspy_to_zeerex_xsl\n" if $debug;
92     my $xsl_doc = $libxml->parse_file($irspy_to_zeerex_xsl);
93     my $irspy_to_zeerex_style = $xslt->parse_stylesheet($xsl_doc);
94
95     my $this = bless {
96         conn => $conn,
97         query => "cql.allRecords=1", # unless overridden
98         modn => undef,          # Filled in by restrict_modulo()
99         modi => undef,          # Filled in by restrict_modulo()
100         targets => undef,       # Filled in later if targets() is
101                                 # called; used only to keep state from
102                                 # targets() until initialise() is
103                                 # called.
104         connections => undef,   # Filled in by initialise()
105         queue => undef,         # Filled in by initialise()
106         libxml => $libxml,
107         irspy_to_zeerex_style => $irspy_to_zeerex_style,
108         test => undef,          # Filled in by initialise()
109         timeout => undef,       # Filled in by initialise()
110         tests => undef,         # Tree of tests to be executed
111         activeSetSize => defined $activeSetSize ? $activeSetSize : 10,
112     }, $class;
113     $this->log("irspy", "starting up with database '$dbname'");
114
115     return $this;
116 }
117
118
119 sub log {
120     my $this = shift();
121     ZOOM::Log::log(@_);
122 }
123
124
125 sub find_targets {
126     my $this = shift();
127     my($query) = @_;
128
129     $this->{query} = $query;
130 }
131
132
133 # Explicitly nominate a set of targets to check, overriding the
134 # default which is to re-check everything in the database.  Each
135 # target already in the database results in the existing record being
136 # updated; each new target causes a new record to be added.
137 #
138 sub targets {
139     my $this = shift();
140     my(@targets) = @_;
141
142     $this->log("irspy", "setting explicit list of targets ",
143                join(", ", map { "'$_'" } @targets));
144     my @qlist;
145     foreach my $target (@targets) {
146         my($protocol, $host, $port, $db, $newtarget) =
147             _parse_target_string($target);
148         if ($newtarget ne $target) {
149             $this->log("irspy_debug", "rewriting '$target' to '$newtarget'");
150             $target = $newtarget; # This is written through the ref
151         }
152         push @qlist, cql_target($protocol, $host, $port, $db);
153     }
154
155     $this->{targets} = \@targets;
156     $this->{query} = join(" or ", @qlist);
157 }
158
159
160 # Also used by ZOOM::IRSpy::Record
161 sub _parse_target_string {
162     my($target) = @_;
163
164     my($protocol, $host, $port, $db) = ($target =~ /(.*?):(.*?):(.*?)\/(.*)/);
165     if (!defined $host) {
166         $port = 210;
167         ($protocol, $host, $db) = ($target =~ /(.*?):(.*?)\/(.*)/);
168         $target = irspy_make_identifier($protocol, $host, $port, $db);
169     }
170     die "$0: invalid target string '$target'"
171         if !defined $host;
172
173     return ($protocol, $host, $port, $db, $target);
174 }
175
176
177 sub restrict_modulo {
178     my $this = shift();
179     my($n, $i) = @_;
180
181     $this->{modn} = $n;
182     $this->{modi} = $i;
183 }
184
185
186 # Records must be fetched for all records satisfying $this->{query} If
187 # $this->{targets} is already set (i.e. a specific list of targets to
188 # check was specified by a call to targets()), then new, empty records
189 # will be made for any targets that are not already in the database.
190 #
191 sub initialise {
192     my $this = shift();
193     my($tname) = @_;
194
195     $tname = "Main" if !defined $tname;
196     $this->{test} = $tname;
197     $this->{tree} = $this->_gather_tests($tname)
198         or die "No tests defined for '$tname'";
199     $this->{tree}->resolve();
200     #$this->{tree}->print(0);
201
202     $this->{timeout} = "ZOOM::IRSpy::Test::$tname"->timeout();
203
204     my @targets;
205     my $targets = $this->{targets};
206     if (defined $targets) {
207         @targets = @$targets;
208         delete $this->{targets};
209     } else {
210         my $rs = $this->{conn}->search(new ZOOM::Query::CQL($this->{query}));
211         $this->log("irspy", "'", $this->{query}, "' found ",
212                    $rs->size(), " target records");
213         delete $this->{query};
214
215         foreach my $i (1 .. $rs->size()) {
216             push @targets, render_record($rs, $i-1, "id");
217         }
218     }
219
220     my $n = $this->{activeSetSize};
221     $n = @targets if $n == 0 || $n > @targets;
222
223     $this->{queue} = \@targets;
224     $this->{connections} = [];
225     while (@{ $this->{connections} } < $n) {
226         my $conn = $this->_next_connection();
227         last if !defined $conn;
228         push @{ $this->{connections} }, $conn;
229     }
230 }
231
232
233 sub _next_connection {
234     my $this = shift();
235
236     my $target;
237     my $n = $this->{modn};
238     my $i = $this->{modi};
239     if (!defined $n) {
240         $target = shift @{ $this->{queue} };
241         return undef if !defined $target;
242     } else {
243         while (1) {
244             $target = shift @{ $this->{queue} };
245             return undef if !defined $target;
246             my $h = _hash($target);
247             my $hmodn = $h % $n;
248             last if $hmodn == $i;
249             #$this->log("irspy", "'$target' hash $h % $n = $hmodn != $i");
250         }
251     }
252
253     die "oops -- target is undefined" if !defined $target;
254     return create ZOOM::IRSpy::Connection($this, $target, async => 1,
255                                           timeout => $this->{timeout});
256 }
257
258
259 sub _hash {
260     my($target) = @_;
261
262     my $n = 0;
263     foreach my $s (split //, $target) {
264         $n += ord($s);
265     }
266
267     return $n;
268 }
269
270
271 sub _irspy_to_zeerex {
272     my $this = shift();
273     my($conn, $save_xml) = @_;
274     my $irspy_doc = $conn->record()->{zeerex}->ownerDocument;
275
276     if ($save_xml) {
277         unlink('/tmp/irspy_orig.xml');
278         open FH, '>/tmp/irspy_orig.xml'
279             or die "can't write irspy_orig.xml: $!";
280         print FH $irspy_doc->toString();
281         close FH;
282     }
283     my %params = ();
284     my $result = $this->{irspy_to_zeerex_style}->transform($irspy_doc, %params);
285     if ($save_xml) {
286         unlink('/tmp/irspy_transformed.xml');
287         open FH, '>/tmp/irspy_transformed.xml'
288             or die "can't write irspy_transformed.xml: $!";
289         print FH $result->toString();
290         close FH;
291     }
292
293     return $result->documentElement();
294 }
295
296
297 sub _rewrite_record {
298     my $this = shift();
299     my($conn) = @_;
300
301     $conn->log("irspy", "rewriting XML record");
302     my $rec = $this->_irspy_to_zeerex($conn, $ENV{IRSPY_SAVE_XML});
303
304     # Since IRSpy can run for a long time between writes back to the
305     # database, it's quite possible for the server to have closed the
306     # connection as idle.  So re-establish it if necessary.
307     $this->{conn}->connect($conn->option("host"));
308
309     _really_rewrite_record($this->{conn}, $rec);
310     $conn->log("irspy", "rewrote XML record");
311 }
312
313
314 sub _really_rewrite_record {
315     my($conn, $rec, $oldid) = @_;
316
317     my $p = $conn->package();
318     $p->option(action => "specialUpdate");
319     my $xml = $rec->toString();
320     $p->option(record => $xml);
321     $p->send("update");
322     $p->destroy();
323
324     # This is the expression in the ID-making stylesheet
325     # ../../zebra/zeerex2id.xsl
326     my $xc = irspy_xpath_context($rec);
327     my $id = irspy_record2identifier($xc);
328     if (defined $oldid && $id ne $oldid) {
329         warn "IDs differ (old='$oldid' new='$id')";
330         _delete_record($conn, $oldid);
331     }
332
333     $p = $conn->package();
334     $p->send("commit");
335     $p->destroy();
336     if (0) {
337         $xml =~ s/&/&amp/g;
338         $xml =~ s/</&lt;/g;
339         $xml =~ s/>/&gt;/g;
340         print "Updated $conn with xml=<br/>\n<pre>$xml</pre>\n";
341     }
342 }
343
344
345 sub _delete_record {
346     my($conn, $id) = @_;
347
348     # We can't delete records using recordIdOpaque, since character
349     # sets are handled differently here in extended services from how
350     # they are used in the Alvis filter's record-parsing, and so
351     # non-ASCII characters come out differently in the two contexts.
352     # Instead, we must send a record whose contents indicate the ID of
353     # that which we wish to delete.  There are two ways, both
354     # unsatisfactory: we could either fetch the actual record them
355     # resubmit it in the deletion request (which wastes a search and a
356     # fetch) or we could build a record by hand from the parsed-out
357     # components (which is error-prone and which I am not 100% certain
358     # will work since the other contents of the record will be
359     # different).  The former evil seems to be the lesser.
360
361     warn "$conn deleting record '$id'";
362
363     my $rs = $conn->search(new ZOOM::Query::CQL(cql_target($id)));
364     die "no such ID '$id'" if $rs->size() == 0;
365     my $rec = $rs->record(0);
366     my $xml = $rec->render();
367
368     my $p = $conn->package();
369     $p->option(action => "recordDelete");
370     $p->option(record => $xml);
371     $p->send("update");
372     $p->destroy();
373
374     $p = $conn->package();
375     $p->send("commit");
376     $p->destroy();
377 }
378
379
380 # The approach: gather declarative information about test hierarchy,
381 # then go into a loop.  In the loop, we ensure that each connection is
382 # running a test, and within that test a task, until its list of tests
383 # is exhausted.  No individual test ever calls wait(): tests just queue
384 # up tasks and return immediately.  When the tasks are run (one at a
385 # time on each connection) they generate events, and it is these that
386 # are harvested by ZOOM::event().  Since each connection knows what
387 # task it is running, it can invoke the appropriate callbacks.
388 # Callbacks return a ZOOM::IRSpy::Status value which tells the main
389 # loop how to continue.
390 #
391 # Invariants:
392 #       While a connection is running a task, its current_task()
393 #       points at the task structure.  When it finishes its task, 
394 #       next_task() is pointed at the next task to execute (if there
395 #       is one), and its current_task() is set to zero.  When the next
396 #       task is executed, the connection's next_task() is set to zero
397 #       and its current_task() pointed to the task structure.
398 #       current_task() and next_task() are both zero only when there
399 #       are no more queued tasks, which is when a new test is
400 #       started.
401 #
402 #       Each connection's current test is stored in its
403 #       "current_test_address" option.  The next test to execute is
404 #       calculated by walking the declarative tree of tests.  This
405 #       option begins empty; the "next test" after this is of course
406 #       the root test.
407 #
408 sub check {
409     my $this = shift();
410
411     my $topname = $this->{tree}->name();
412     my $timeout = $this->{timeout};
413     $this->log("irspy", "beginnning with test '$topname' (timeout $timeout)");
414
415     my $nskipped = 0;
416     my @conn = @{ $this->{connections} };
417
418     my $nruns = 0;
419   ROUND_AND_ROUND_WE_GO:
420     while (1) {
421         my @copy_conn = @conn;  # avoid alias problems after splice()
422         my $nconn = scalar(@copy_conn);
423         foreach my $i0 (0 .. $#copy_conn) {
424             my $conn = $copy_conn[$i0];
425             #print "connection $i0 of $nconn/", scalar(@conn), " is $conn\n";
426             next if !defined $conn;
427             if (!$conn->current_task()) {
428                 if (!$conn->next_task()) {
429                     # Out of tasks: we need a new test
430                   NEXT_TEST:
431                     my $address = $conn->option("current_test_address");
432                     my $nextaddr;
433                     if (!defined $address) {
434                         $nextaddr = "";
435                     } else {
436                         $conn->log("irspy_test",
437                                    "checking for next test after '$address'");
438                         $nextaddr = $this->_next_test($address);
439                     }
440                     if (!defined $nextaddr) {
441                         $conn->log("irspy", "has no more tests: removing");
442                         $this->_rewrite_record($conn);
443                         $conn->option(rewrote_record => 1);
444                         my $newconn = $this->_next_connection();
445                         if (!defined $newconn) {
446                             # Do not destroy: needed for later sanity checks
447                             splice @conn, $i0, 1;
448                         } else {
449                             $conn->destroy();
450                             $conn[$i0] = $newconn;
451                             $conn[$i0]->option(current_test_address => "");
452                             $conn[$i0]->log("irspy", "entering active pool - ",
453                                             scalar(@{ $this->{queue} }),
454                                             " targets remain in queue");
455                         }
456                         next;
457                     }
458
459                     my $node = $this->{tree}->select($nextaddr)
460                         or die "invalid nextaddr '$nextaddr'";
461                     $conn->option(current_test_address => $nextaddr);
462                     my $tname = $node->name();
463                     $conn->log("irspy_test",
464                                "starting test '$nextaddr' = $tname");
465                     my $tasks = $conn->tasks();
466                     my $oldcount = @$tasks;
467                     "ZOOM::IRSpy::Test::$tname"->start($conn);
468                     $tasks = $conn->tasks();
469                     if (@$tasks > $oldcount) {
470                         # Prepare to start the first of the newly added tasks
471                         $conn->next_task($tasks->[$oldcount]);
472                     } else {
473                         $conn->log("irspy_task",
474                                    "no tasks added by new test $tname");
475                         goto NEXT_TEST;
476                     }
477                 }
478
479                 my $task = $conn->next_task();
480                 die "no next task queued for $conn" if !defined $task;
481                 $conn->log("irspy_task", "preparing task $task");
482                 $conn->next_task(0);
483                 $conn->current_task($task);
484                 $task->run();
485             }
486         }
487
488       NEXT_EVENT:
489         my $i0 = ZOOM::event(\@conn);
490         $this->log("irspy_event",
491                    "ZOOM_event(", scalar(@conn), " connections) = $i0");
492         if ($i0 < 1) {
493             my %messages = (
494                             0 => "no events remain",
495                             -1 => "ZOOM::event() argument not a reference",
496                             -2 => "ZOOM::event() reference not an array",
497                             -3 => "no connections remain",
498                             -4 => "too many connections for ZOOM::event()",
499                             );
500             my $message = $messages{$i0} || "ZOOM::event() returned $i0";
501             $this->log("irspy", $message);
502             last;
503         }
504
505         my $conn = $conn[$i0-1];
506         my $ev = $conn->last_event();
507         my $evstr = ZOOM::event_str($ev);
508         $conn->log("irspy_event", "event $ev ($evstr)");
509         goto NEXT_EVENT if $ev != ZOOM::Event::ZEND;
510
511         my $task = $conn->current_task();
512         die "$conn has no current task for event $ev ($evstr)" if !$task;
513
514         my $res;
515         eval { $conn->check() };
516         if ($@ && ref $@ && $@->isa("ZOOM::Exception")) {
517             my $sub = $task->{cb}->{exception};
518             die $@ if !defined $sub;
519             $res = &$sub($conn, $task, $task->udata(), $@);
520         } elsif ($@) {
521             die "Unexpected non-ZOOM exception: " . ref($@) . " ($@)";
522         } else {
523             my $sub = $task->{cb}->{$ev};
524             if (!defined $sub) {
525                 $conn->log("irspy_unhandled", "event $ev ($evstr)");
526                 next;
527             }
528
529             $res = &$sub($conn, $task, $task->udata(), $ev);
530         }
531
532         if ($res == ZOOM::IRSpy::Status::OK) {
533             # Nothing to do -- life continues
534
535         } elsif ($res == ZOOM::IRSpy::Status::TASK_DONE) {
536             my $task = $conn->current_task();
537             die "no task for TASK_DONE on $conn" if !$task;
538             die "next task already defined for $conn" if $conn->next_task();
539             $conn->log("irspy_task", "completed task $task");
540             $conn->next_task($task->{next});
541             $conn->current_task(0);
542
543         } elsif ($res == ZOOM::IRSpy::Status::TEST_GOOD ||
544                  $res == ZOOM::IRSpy::Status::TEST_BAD) {
545             my $x = ($res == ZOOM::IRSpy::Status::TEST_GOOD) ? "good" : "bad";
546             $conn->log("irspy_task", "test ended during task $task ($x)");
547             $conn->log("irspy_test", "test completed ($x)");
548             $conn->current_task(0);
549             $conn->next_task(0);
550             if ($res == ZOOM::IRSpy::Status::TEST_BAD) {
551                 my $address = $conn->option('current_test_address');
552                 $conn->log("irspy", "top-level test failed!")
553                     if $address eq "";
554                 my $node = $this->{tree}->select($address);
555                 my $skipcount = 0;
556                 while (defined $node->next() &&
557                        length($node->next()->address()) >= length($address)) {
558                     $conn->log("irspy_debug", "skipping from '",
559                                $node->address(), "' to '",
560                                $node->next()->address(), "'");
561                     $node = $node->next();
562                     $skipcount++;
563                 }
564
565                 $conn->option(current_test_address => $node->address());
566                 $conn->log("irspy_test", "skipped $skipcount tests");
567                 $nskipped += $skipcount;
568             }
569
570         } elsif ($res == ZOOM::IRSpy::Status::TEST_SKIPPED) {
571             $conn->log("irspy_test", "test skipped during task $task");
572             $conn->current_task(0);
573             $conn->next_task(0);
574             $nskipped++;
575
576         } else {
577             die "unknown callback return-value '$res'";
578         }
579     }
580
581     $this->log("irspy", "exiting main loop");
582
583     # Sanity checks: none of the following should ever happen
584     my $finished = 1;
585     $this->log("irspy", "performing end-of-run sanity-checks");
586     foreach my $conn (@conn) {
587         my $test = $conn->option("current_test_address");
588         my $next = $this->_next_test($test);
589         if (defined $next) {
590             $this->log("irspy",
591                        "$conn (in test '$test') has queued test '$next'");
592             $finished = 0;
593         }
594         if (my $task = $conn->current_task()) {
595             $this->log("irspy", "$conn still has an active task $task");
596             $finished = 0;
597         }
598         if (my $task = $conn->next_task()) {
599             $this->log("irspy", "$conn still has a queued task $task");
600             $finished = 0;
601         }
602         if (!$conn->is_idle()) {
603             $this->log("irspy",
604                        "$conn still has ZOOM-C level tasks queued: see below");
605             $finished = 0;
606         }
607         my $ev = $conn->peek_event();
608         if ($ev != 0 && $ev != ZOOM::Event::ZEND) {
609             my $evstr = ZOOM::event_str($ev);
610             $this->log("irspy", "$conn has event $ev ($evstr) waiting");
611             $finished = 0;
612         }
613         if (!$conn->option("rewrote_record")) {
614             $this->log("irspy", "$conn did not rewrite its ZeeRex record");
615             $finished = 0;
616         }
617     }
618
619     # This really shouldn't be necessary, and in practice it rarely
620     # helps, but it's belt and braces.  (For now, we don't do this
621     # hence the zero in the $nruns check).
622     if (!$finished) {
623         if (++$nruns < 0) {
624             $this->log("irspy", "back into main loop, ${nruns}th time");
625             goto ROUND_AND_ROUND_WE_GO;
626         } else {
627             $this->log("irspy", "bailing after $nruns main-loop runs");
628         }
629     }
630
631     # This shouldn't happen emit anything either:
632     while ((my $i1 = ZOOM::event(\@conn)) > 0) {
633         my $conn = $conn[$i1-1];
634         my $ev = $conn->last_event();
635         my $evstr = ZOOM::event_str($ev);
636         $this->log("irspy",
637                    "$conn still has ZOOM-C level task queued: $ev ($evstr)")
638             if $ev != ZOOM::Event::ZEND;
639     }
640
641     return $nskipped;
642 }
643
644
645 # Exactly equivalent to ZOOM::event() except that it is tolerant to
646 # undefined values in the array being passed in.
647 #
648 sub __UNUSED_tolerant_ZOOM_event {
649     my($connref) = @_;
650
651     my(@conn, @map);
652     foreach my $i (0 .. @$connref-1) {
653         my $conn = $connref->[$i];
654         if (defined $conn) {
655             push @conn, $conn;
656             push @map, $i;
657         }
658     }
659
660     my $res = ZOOM::event(\@conn);
661     return $res if $res <= 0;
662     my $res2 = $map[$res-1] + 1;
663     print STDERR "*** tolerant_ZOOM_event() returns $res->$res2\n";
664     return $res2;
665 }
666
667
668 sub _gather_tests {
669     my $this = shift();
670     my($tname, @ancestors) = @_;
671
672     die("$0: test-hierarchy loop detected: " .
673         join(" -> ", @ancestors, $tname))
674         if grep { $_ eq $tname } @ancestors;
675
676     my $slashSeperatedTname = $tname;
677     $slashSeperatedTname =~ s/::/\//g;
678     my $fullName = "ZOOM/IRSpy/Test/$slashSeperatedTname.pm";
679
680     eval {
681         require $fullName;
682     }; if ($@) {
683         $this->log("irspy", "couldn't require '$fullName': $@");
684         $this->log("warn", "can't load test '$tname': skipping",
685                    $@ =~ /^Can.t locate/ ? () : " ($@)");
686         return undef;
687     }
688
689     $this->log("irspy", "adding test '$tname'");
690     my @subnodes;
691     foreach my $subtname ("ZOOM::IRSpy::Test::$tname"->subtests($this)) {
692         my $subtest = $this->_gather_tests($subtname, @ancestors, $tname);
693         push @subnodes, $subtest if defined $subtest;
694     }
695
696     return new ZOOM::IRSpy::Node($tname, @subnodes);
697 }
698
699
700 # These next three should arguably be Node methods
701 sub _next_test {
702     my $this = shift();
703     my($address, $omit_child) = @_;
704
705     # Try first child
706     if (!$omit_child) {
707         my $maybe = $address eq "" ? "0" : "$address:0";
708         return $maybe if $this->{tree}->select($maybe);
709     }
710
711     # The top-level node has no successor or parent
712     return undef if $address eq "";
713
714     # Try next sibling child
715     my @components = split /:/, $address;
716     my $last = pop @components;
717     my $maybe = join(":", @components, $last+1);
718     return $maybe if $this->{tree}->select($maybe);
719
720     # This node is exhausted: try the parent's successor
721     return $this->_next_test(join(":", @components), 1)
722 }
723
724
725 sub _last_sibling_test {
726     my $this = shift();
727     my($address) = @_;
728
729     return undef
730         if !defined $this->_next_sibling_test($address);
731
732     my $nskipped = 0;
733     while (1) {
734         my $maybe = $this->_next_sibling_test($address);
735         last if !defined $maybe;
736         $nskipped++;
737         $address = $maybe;
738         $this->log("irspy", "skipping $nskipped tests to '$address'");
739     }
740
741     return ($address, $nskipped);
742 }
743
744
745 sub _next_sibling_test {
746     my $this = shift();
747     my($address) = @_;
748
749     my @components = split /:/, $address;
750     my $last = pop @components;
751     my $maybe = join(":", @components, $last+1);
752     return $maybe if $this->{tree}->select($maybe);
753     return undef;
754 }
755
756
757 =head1 SEE ALSO
758
759 ZOOM::IRSpy::Record,
760 ZOOM::IRSpy::Web,
761 ZOOM::IRSpy::Test,
762 ZOOM::IRSpy::Maintenance.
763
764 The ZOOM-Perl module,
765 http://search.cpan.org/~mirk/Net-Z3950-ZOOM/
766
767 The Zebra Database,
768 http://indexdata.com/zebra/
769
770 =head1 AUTHOR
771
772 Mike Taylor, E<lt>mike@indexdata.comE<gt>
773
774 =head1 COPYRIGHT AND LICENSE
775
776 Copyright (C) 2006 by Index Data ApS.
777
778 This library is free software; you can redistribute it and/or modify
779 it under the same terms as Perl itself, either Perl version 5.8.7 or,
780 at your option, any later version of Perl 5 you may have available.
781
782 =cut
783
784
785 1;