The London Perl and Raku Workshop takes place on 26th Oct 2024. If your company depends on Perl, please consider sponsoring and/or attending.
package t::Client;

use strict;
use warnings;
use Data::Dumper;
use Carp;

use JSON::XS;

our $HAVE_ALGORITHM_DIFF;
BEGIN {
    eval "use Algorithm::Diff";
    $HAVE_ALGORITHM_DIFF = 1 unless $@;
}


use constant DEBUG => 0;
my $NODES = {};

*is = \&main::is;
*ok = \&main::ok;
*isnt = \&main::isnt;
*diag = \&main::diag;
*fail = \&main::fail;
*pass = \&main::pass;


#################################################################
sub new 
{
    my $package = shift;
    my $self = bless { @_ }, $package;
    $self->{APP} ||= 'Test';
    $self->{PORT} ||= 8881;
    $self->{HOST} ||= 'localhost';
    $self->{name} ||= '';
    $self->{NODES} = $NODES;
    $self->{NODES} = {} if $self->{parent};
    $self->{R} = 0;
    return $self;
}


######################################################
sub get_node
{
    my( $self, $id ) = @_;
    return $self->{NODES}->{$id};
}

######################################################
sub find_ID
{
    my( $self, $id ) = @_;
    return $self->{NODES}->{ $id };
}

######################################################
sub decode_resp
{
    my( $self, $resp, $phase ) = @_;
    ok( $resp->is_success, "'$phase' successful" );
    
    is( scalar $resp->content_type, 'application/json', 
            "Right content type" ) or die $resp->content;

    my $content = $resp->content;
    # warn "content='$content'";
    my $data;
    if( $JSON::XS::VERSION > 2 ) {
        $data = JSON::XS::decode_json( $content );
    }
    else {
        $data = JSON::XS::from_json( $content );
    }
    is( ref( $data ), 'ARRAY', "'$phase' returned an array" ) or die Dumper $data;
    return $data;
}

######################################################
sub check_boot
{
    my( $self, $data ) = @_;

    is( $data->[0][0], 'SID', "First response is the SID" );
    my $n = 1;
    if( $data->[$n][0] ne 'for' ) {
        is( $data->[$n][0], 'boot', "Second response is the boot" );
        ok( $data->[$n][1],         " ... message" );
        $n++;
    }
    is( $data->[$n][0], 'for', "Next response is for" );
    is( $data->[$n][1], '',    " ... the main window" );
    $n++;
    is( $data->[$n][0], 'new', "Last response is the new" );
    is( $data->[$n][2], 'window', " ... window" );
}

######################################################
sub handle_resp
{
    my( $self, $data, $phase ) = @_;

    local $self->{deleted} = {};
    return unless @$data;

#    warn Dumper $data;
    my %accume;
    my $for = $self->{name};
    foreach my $I ( @$data ) {
        next unless $I->[0];
        if( $I->[0] eq 'for' ) {
            $for = $I->[1];
            next;
        }
        if( $for eq $self->{name} ) {
            $self->handle_one( @$I );
        }
        else {
            push @{ $accume{$for} }, $I;
        }
    }

    if( %accume ) {
        # warn "accume=", Dumper \%accume;
        if( $self->{parent} ) {
            $self->{parent}->for_window( \%accume, $phase );
        }
        else {
            $self->for_window( \%accume, $phase );
        }
    }
    my $close = delete $self->{close_window};
    return unless $close and @$close;
    foreach my $id ( @$close ) {
        $self->close_window( $id );
    }
}

######################################################
sub for_window
{
    my( $self, $accume, $phase ) = @_;
    Carp::carp "Must be parent" if $self->{parent} or $self->{name};

    foreach my $id ( keys %$accume ) {
        my $win = $self->{windows}{ $id };
        $win = $win->{browser} if $win;
        my $name = $id;
        if( $id eq '' ) {
            $win = $self;
            $name = 'main window';
        }
    
        ok( $win, "Instructions for |$name|" ) or die "PAIN FOLLOWS";
        $win->handle_resp( $accume->{ $id }, $phase );
    }
}

######################################################
sub handle_one
{
    my( $self, $op, $id, @args ) = @_;
    return unless $op;
    if( $op eq 'ERROR' ) {
        die $args[0];
    }

    return if $id and $self->{deleted}{ $id };
    if( $op eq 'SID' ) {
        ok( !$self->{SID}, "New SID $id" );
        $self->{SID} = $id;
    }
    elsif( $op eq 'boot' ) {
        ok( !$self->{boot}, "Boot message '$id'" );
        $self->{boot} = $id;
    }
    elsif( $op eq 'textnode' ) {
        ok( defined( $args[1] ), "Got a $id.textnode" );
        ok( $self->{NODES}->{$id}, " ... and we have its parent ($id)" )
                or die "$self->NODES=", 
                           sort keys %{ $self->{NODES} };
        my $parent = $self->{NODES}->{$id}{zC};
        if( $args[0] < 0 ) {
            push @$parent, { tag=>'textnode', nodeValue=>$args[1] };
        } 
        else {
            ok( ( $args[0] <= @{$parent} ), " ... and this isn't impossible" );
            my $tn = $parent->[ $args[0] ];
            if( $tn and $tn->{tag} eq 'textnode' ) {
                $tn->{nodeValue} = $args[1];
            }
            else {
                $parent->[ $args[0] ] =
                    { tag=>'textnode', nodeValue=>$args[1] };
            }
        }
    }
    elsif( $op eq 'cdata' ) {
        ok( defined( $args[1] ), "Got a cdata $id" );
        ok( $self->{NODES}->{$id}, " ... and we have its parent ($id)" );
        my $parent = $self->{NODES}->{$id}{zC};
        ok( ( $args[0] <= @{$parent} ), " ... and this isn't way out there" );
        if( $args[0] < 0 ) {
            push @$parent, { tag=>'cdata', cdata=>$args[1] };
        }
        else {
            my $tn = $parent->[ $args[0] ];
            if( $tn and $tn->{tag} eq 'cdata' ) {
                $tn->{nodeValue} = $args[1];
            }
            else {
                $parent->[ $args[0] ] =
                    { tag=>'cdata', cdata=>$args[1] };
            }
        }
    }
    elsif( $op eq 'new' ) {
        ok( ! $self->{NODES}->{$id}, "New node $id" );
        ok( $args[0], " ... with a tag type" );
        my $new = $self->{NODES}->{$id} = 
                    { tag => $args[0], id=>$id, zC=>[] };
        if( $args[1] ) {
            my $parent = $self->{NODES}->{$args[1]};
            ok( $parent, " ... and we have its parent ($args[0] wants $args[1])" );

            if( $args[2] < 0 ) {
                $parent->{zC} ||= [];
                push @{ $parent->{zC} }, $new;
            }
            else {
                my $old = $parent->{zC}[ $args[2] ];
                if( $old ) {
                    delete $self->{NODES}->{ $old->{id} };
                }

                $parent->{zC}[ $args[2] ] = $new;
            }
        }
        if( ($new->{tag}||'') eq 'window' ) {
            ok( !$self->{W}, "New window" );
            $self->{W} = $new;
        }
    }
    elsif( $op eq 'set' ) {
        ok( 2==@args, "Going to set attribute $args[0]" );
        my $m = 'an existing node'; 
        $m = $args[1] if $args[0] eq 'id';
        ok( $self->{NODES}->{$id}, " ... on $m" )
                or die "Where is $id in ", join ', ', sort keys %{ $self->{NODES} }, 
                                Dumper [ $op, $id, @args ];

        isnt( $self->{NODES}->{$id}{tag}, 'textnode', 
                        "One can't reference a text node!" );

        if( $args[0] eq 'id' ) {
            my $N = delete $self->{NODES}->{$id};
            DEBUG and diag( "$N->{id} -> $args[1]" );
            $N->{id} = $args[1];
            $self->{NODES}->{ $N->{id} } = $N;
        }
        else {
            $self->{NODES}->{$id}{$args[0]} = $args[1];
        }
    }
    elsif( $op eq 'remove' ) {
        ok( 1==@args, "Going to remove attribute $args[0]" );
        ok( $self->{NODES}->{$id}, " ... on an existing node" )
                or die "Where is $id in ", join ', ', sort keys %{ $self->{NODES} }, 
                                Dumper [ $op, $id, @args ];

        delete $self->{NODES}->{$id}{$args[0]};
    }
    elsif( $op eq 'bye' ) {
        next unless $self->{NODES}->{$id};

        ok( 0==@args, "Going to delete element $id" );
        ok( $self->{NODES}->{$id}, " ... we know that node" );
        isnt( $self->{NODES}->{$id}{tag}, 'textnode', 
                        " ... can't reference a text node" );
        my $old = delete $self->{NODES}->{$id};

        my( $parent, $index ) = $self->find_parent( $old );

        if( $parent and defined $index ) {
            ok( $parent, " ... and we know the parent" );
            ok( defined $index, " ... we know the offset" );
            my $node = splice @{ $parent->{zC} }, $index, 1;
            is( $old, $node, " ... it's right node" );
        }
        else {
            pass( " ... parent is already bye-bye" );
        }
        $self->drop_node( $old );
    }
    elsif( $op eq 'bye-textnode' ) {
        ok( 1==@args, "Going to delete textnode $args[0] from $id" );
        if( $self->{NODES}->{$id} ) {
            ok( $self->{NODES}->{$id}, " ... we know of the node" );
            ok( ( $args[0] < @{ $self->{NODES}->{$id}{zC} } ), " ... in range" );
            my $node = splice @{ $self->{NODES}->{$id}{zC} }, $args[0], 1;
            is( $node->{tag}, 'textnode', " ... it's a textnode" );
        }
        else {
            pass( " ... already bye-bye" );
        }
    }
    elsif( $op eq 'framify' ) {
        ok( 0==@args, "Going to framify element $id" );
        ok( $self->{NODES}->{$id}, " ... we know of the node" );
        isnt( $self->{NODES}->{$id}{tag}, 'textnode', 
                        " ... can't framify a text node" );
        my $old = delete $self->{NODES}->{$id};

        my( $parent, $index ) = $self->find_parent( $old );

        ok( $parent, " ... and we know the parent of $old->{id}" )
                or die "We need to know the parent!";
        ok( ( $index < @{ $parent->{zC} } ), " ... in range" );

        my $new = {
                    tag => 'iframe',
                    id  => "IFRAME-$old->{id}",
                    src => { type      => 'XUL-from', 
                             source_id => $old->{id}
                           }
                };
        ok( !$self->{NODES}->{$new->{id}}, " ... never been framified" );
        $self->{NODES}->{$new->{id}} = $new;

        my $node = splice @{ $parent->{zC} }, $index, 1, $new;
        is( $old, $node, " ... it's right node" );
        $self->drop_node( $node );
    }
    elsif( $op eq 'timeslice' ) {
        # ignore
    }
    elsif( $op eq 'popup_window' ) {
        $self->popup_window( $id, @args );
    }
    elsif( $op eq 'close_window' ) {
        push @{ $self->{close_window} }, $id;
    }
    elsif( $op eq 'timeslice' ) {
        # ignore it
    }
    elsif( $op eq 'style' ) {
        ok( 2==@args, "Going to set style $args[0]" );
        ok( $self->{NODES}->{$id}, " ... on an existing node" )
                or die "Where is $id in ", join ', ', sort keys %{ $self->{NODES} }, 
                                Dumper [ $op, $id, @args ];

        my $N = $self->{NODES}->{$id};
        
        isnt( $N->{tag}, 'textnode', "One can't set the style of a text node!" );
        
        $self->{style} ||= {};
        if( not ref $N->{style} ) {
            $N->{style} = { map { split /:\s*/, $_, 2 } 
                                split /;\s*/, $N->{style} #**
                          };
        }
        $N->{style}{$args[0]} = $args[1];
    }
    else {
         die "What do i do with op=$op";
    }
}

######################################################
sub find_parent
{
    my( $self, $node ) = @_;
    return unless defined $node;
    foreach my $N ( values %{$self->{NODES}} ) {
        next if $N->{tag} eq 'textnode' or $N->{tag} eq 'cdata';
        use Data::Dumper;
        die Dumper $N unless $N->{zC};
        for( my $q1=0; $q1 < @{ $N->{zC} }; $q1++ ) {
            unless( defined $N->{zC}[ $q1 ] ) {
                # die "$q1=", Dumper $N->{zC};
                next;
            }
            next unless $N->{zC}[$q1] == $node;
            return $N, $q1 if wantarray;
            return $N;
        }
    }
    return;
}

############################################################
sub is_visible
{
    my( $self, $node ) = @_;
    $node = $self->find_ID( $node ) unless ref $node;
    return unless $node;
    my $style = $self->style( $node );
    return not ( $style =~ /display:\s*none/ );
}

######################################################
sub style
{
    my( $self, $node ) = @_;
    my $S = $node->{style};
    return '' unless $S;
    return $S unless ref $S;
    
    return join "\n", map { "$_: $S->{$_};" } sort keys %$S;
}


######################################################
sub nodeText
{
    my( $self, $node ) = @_;
    return $node->{nodeValue} if $node->{tag} eq 'textnode';
    my @ret;
    foreach my $N ( @{ $node->{zC} } ) {
        push @ret, $self->nodeText( $N );
    }
    return @ret if wantarray;
    return join " ", @ret;
}

######################################################
sub drop_node
{
    my( $self, $node ) = @_;
    if( $node->{id} ) {
        delete $self->{NODES}->{ $node->{id} };
        $self->{deleted}{ $node->{id} } = 1;
    }

    return if not $node->{tag} or $node->{tag} eq 'textnode' or $node->{tag} eq 'cdata';
    foreach my $C ( @{ $node->{zC} } ) {
        $self->drop_node( $C );
    }
    $node->{zC} = [];
}

######################################################
sub root_uri
{
    my( $self ) = @_;
    return URI->new( "http://$self->{HOST}:$self->{PORT}/" );
}

######################################################
sub base_uri
{
    my( $self ) = @_;
    return URI->new( "http://$self->{HOST}:$self->{PORT}/xul" );
}

######################################################
sub default_args
{
    my( $self ) = @_;
    return ( version=>1, window=>$self->{name}, reqN=> $self->{R}++ );
}

######################################################
sub boot_uri
{
    my( $self ) = @_;
    my $URI = $self->base_uri;
    $URI->query_form( $self->boot_args );
    return $URI;
}

######################################################
sub boot_args
{
    my( $self, $button ) = @_;
    return { $self->default_args, app=> $self->{APP} };
}

######################################################
sub list_ID
{
    my( $self ) = @_;
    my @list = sort grep { !/^PX/ } keys %{ $self->{NODES} };
    return join ', ', @list unless wantarray;
    return @list;
}

sub find_by_tag
{
    my( $self, $tag ) = @_;

    return map { $self->find_ID( $_ ) } $self->list_by_tag( $tag );
}

sub list_by_tag
{
    my( $self, $tag ) = @_;
    my @list;
    foreach my $id ( keys %{ $self->{NODES} } ) {
        my $node = $self->find_ID( $id );
        next unless $node->{tag} eq $tag;
        push @list, $id;
    }
    return join ', ', @list unless wantarray;
    return @list;
}

sub find_by_attr
{
    my( $self, $attr, $want ) = @_;
    foreach my $node ( values %{ $self->{NODES} } ) {
        next unless ($node->{$attr}||'') eq $want;
        return $node;
    }
    return;
}


######################################################
sub test_node
{
    my( $self, $node, $type ) = @_;
    my $name = $node;
    unless( ref $node ) {
        $node = $self->find_ID( $node );
    }
    else {
        $name = $node->{id};
    }
    $type ||= 'node';
    ok( $node, "Found $type $name" )
            or die "I really need that $type\nHave: ". $self->list_ID;
    return $node;    
}

######################################################
sub Click
{
    my( $self, $button ) = @_;

    unless( ref $button ) {
        diag( "Clicking $button" ) if not $ENV{AUTOMATED_TESTING};
        $button = $self->test_node( $button, 'button' );
    }
    else {
        diag( "Clicking $button->{id}" ) if not $ENV{AUTOMATED_TESTING};
    }

    my $URI = $self->Click_uri( $button );
    my $resp = $self->{UA}->get( $URI );
    my $data = $self->decode_resp( $resp, "Click $button->{id}" );
    die $data->[0][2] if $data->[0][0] eq 'ERROR';
    $self->handle_resp( $data, "Click $button->{id}" );
}

######################################################
sub Click_uri
{
    my( $self, $button ) = @_;
    ok( $button->{id}, "Clicking on [ $button->{id} ]" );
    my $URI = $self->base_uri;
    $URI->query_form( $self->Click_args( $button ) );
    return $URI;
}

######################################################
sub Click_args
{
    my( $self, $button ) = @_;
    return {    $self->default_args, 
                app => $self->{APP}, 
                SID => $self->{SID}, 
                event => 'Click', 
                source_id => $button->{id}
            };
}

######################################################
sub Change
{
    my( $self, $node, $value ) = @_;
    $node = $self->find_ID( $node ) unless ref $node;
    ok( $node, "Found $node->{id}" ) or die "I really need that node";

    $node->{value} = $value;

    my $URI = $self->Change_uri( $node );
    my $resp = $self->{UA}->get( $URI );
    my $data = $self->decode_resp( $resp, "Change $node->{id}" );
    die Dumper $data if $data->[0] and $data->[0][0] eq 'ERROR';
    $self->handle_resp( $data, "Change $node->{id}" );    
}

######################################################
sub Change_uri
{
    my( $self, $textbox ) = @_;
    ok( $textbox->{id}, "Changing on $textbox->{id}" );
    my $URI = $self->base_uri;
    $URI->query_form( $self->Change_args( $textbox ) );
    return $URI;
}

######################################################
sub Change_args
{
    my( $self, $textbox ) = @_;
    return {    $self->default_args, 
                app => $self->{APP}, 
                SID => $self->{SID}, 
                event => 'Change', 
                source_id => $textbox->{id},
                value => $textbox->{value}
            };
}

######################################################
sub Select_args
{
    my( $self, $textbox ) = @_;
    return {    $self->default_args, 
                app => $self->{APP}, 
                SID => $self->{SID}, 
                event => 'Select', 
                source_id => $textbox->{id},
                selectedIndex => $textbox->{selectedIndex}
            };
}

######################################################
sub RadioClick_args
{
    my( $self, $RG, $index ) = @_;

    ok( $RG, "Going to click a radio" );

    my $selectedId;
    if( ref $index ) {
        $selectedId = $index->{id};
    }
    else {
        my $radio = $RG->{zC}[ $index ];
        ok( $radio, " ... got the radio" );
        is( $radio->{tag}, 'radio', " ... yep, it's a radio" );
        $selectedId = $radio->{id};
    }


    return {    $self->default_args, 
                app => $self->{APP}, 
                SID => $self->{SID}, 
                event => 'RadioClick', 
                source_id => $RG->{id},
                selectedId => $selectedId
            };
}

############################################################
sub Connect
{
    my( $self ) = @_;
    my $URI = $self->Connect_uri;
    my $resp = $self->{UA}->get( $URI );
    my $data = $self->decode_resp( $resp, "Connect $self->{name}" );
    die Dumper $data if $data->[0][0] eq 'ERROR';
    $self->handle_resp( $data, "Connect $self->{name}" );    
}

sub Connect_uri
{
    my( $self ) = @_;

    my $URI = $self->base_uri;
    $URI->query_form( $self->Connect_args );
    return $URI;
}

sub Connect_args
{
    my( $self ) = @_;
    return {    $self->default_args, 
                app => $self->{APP}, 
                SID => $self->{SID},
                event  => 'connect',
                window => $self->{name}
           };
}

######################################################
sub Disconnect
{
    my( $self, $win ) = @_;
    die "Must be parent" if $self->{parent} or $self->{name};
    my $URI = $self->Disconnect_uri( $win );
    my $resp = $self->{UA}->get( $URI );
    my $data = $self->decode_resp( $resp, "Disconnect $win->{name}" );
    # warn "Disconnect name=$self->{name} data=", Dumper $data;
    $self->handle_resp( $data, "Disconnect $win->{name}" );    
}

sub Disconnect_uri
{
    my( $self, $win ) = @_;

    my $URI = $self->base_uri;
    $URI->query_form( $self->Disconnect_args( $win ) );
    return $URI;
}

sub Disconnect_args
{
    my( $self, $win ) = @_;
    return {    $self->default_args, 
                app => $self->{APP}, 
                SID => $self->{SID},
                event  => 'disconnect',
                window => $win->{name},
           };
}

######################################################
sub Close
{
    my( $self ) = @_;
    croak "Not allowed to close the main window!" unless $self->{parent};
    $self->close_window( $self->{name} );
}


######################################################
sub SearchList_args
{
    my( $self, $SL, $string ) = @_;

    ok( $SL, "Going to search a search-list" );
    ok( $string, " ... for '$string'" );

    return {    $self->default_args, 
                app => $self->{APP}, 
                SID => $self->{SID}, 
                event => 'SearchList', 
                source_id => $SL->{id},
                value => $string
            };
}

############################################################
sub server_size
{
    my( $self, $UA ) = @_;
    my $SIZEuri = $self->base_uri;
    $SIZEuri->path( '/__poe_size' );

    my $resp = $UA->get( $SIZEuri );
    ok( $resp->is_success, "Got the kernel size" );
    is( $resp->content_type, 'text/plain', " ... as text/plain" );

    my $size = 0+$resp->content;
    ok( $size, " ... and it is non-null" );
    return $size;
}

############################################################
sub server_dump
{
    my( $self, $UA ) = @_;
    my $URI = $self->base_uri;
    $URI->path( '/__poe_kernel' );

    my $resp = $UA->get( $URI );
    ok( $resp->is_success, "Got the kernel dump" );
    is( $resp->content_type, 'text/plain', " ... as text/plain" );

    return $resp->content;
}

############################################################
sub compare_dumps
{
    my( $self, $DUMP1, $DUMP2 ) = @_;
    return unless $HAVE_ALGORITHM_DIFF;

    my $diff = Algorithm::Diff->new( [ split "\n", $DUMP1 ],
                                     [ split "\n", $DUMP2 ] );
    $diff->Base( 1 );   # Return line numbers, not indices
    while(  $diff->Next()  ) {
        next   if  $diff->Same();
        my $sep = '';
        if(  ! $diff->Items(2)  ) {
            printf "%d,%dd%d\n",
               $diff->Get(qw( Min1 Max1 Max2 ));
        } elsif(  ! $diff->Items(1)  ) {
            printf "%da%d,%d\n",
               $diff->Get(qw( Max1 Min2 Max2 ));
        } else {
            $sep = "---\n";
            printf "%d,%dc%d,%d\n",
               $diff->Get(qw( Min1 Max1 Min2 Max2 ));
        }
        print "- $_\n"   for  $diff->Items(1);
        # print $sep;
        print "+ $_\n"   for  $diff->Items(2);
    }
}

############################################################
sub popup_window
{
    my( $self, $id, @args ) = @_;
    
    ok( !$self->{windows}{$id}, "Popup window $id" )
            or die "Pain follows";

    push @{ $self->{new_windows} }, $id;
    $self->{windows}{ $id } = { id => $id };
}


############################################################
sub close_window
{
    my( $self, $id ) = @_;
    if( $self->{parent} ) {
        return $self->{parent}->close_window( $id );
    }

    my $win = delete $self->{windows}{$id};
    ok( $win, "Close window $id" )
            or die "Closing a closed window??!";


    $win = $win->{browser};
    $win->{closed} = 1;
    $win->{NODES} = {};
    $self->Disconnect( $win );
    delete $win->{parent};
}

############################################################
sub open_window
{
    my( $self ) = @_;
    my $win_id = pop @{ $self->{new_windows} };
    my $win2 = ref( $self )->new( parent => $self, name => $win_id );

    my @copy = qw( HOST PORT UA APP SID );
    @{ $win2 }{ @copy } = @{ $self }{ @copy };

    $self->{windows}{ $win_id }{browser} = $win2;

    return $win2;
}

1;