The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
#!/usr/bin/perl -w
# vim: ts=2 sw=2 expandtab

# Exercises Wheel::ReadLine

use strict;
use warnings;
use lib qw(./mylib ../mylib);

sub DEBUG () { 0 }

# Set the INPUTRC environment variable to a nonexistent file.  This
# prevents users from overriding the behaviors of keystrokes we user
# here.

BEGIN {
  foreach my $candidate (qw(nonexistent moo deleteme please-dont-exist)) {
    next if -f $candidate;
    $ENV{INPUTRC} = $candidate;
    last;
  }
}

### Tests to run.
#
# Each test consists of a "name" for test reporting, a series of steps
# that contain text to "type" in a particular order, and a "done" line
# that should contain the final input from Wheel::ReadLine.

my @tests = (
  {
    name => "plain typing",
    step => [
      "this is a test", # plain typing
      "\cJ",            # accept-line
    ],
    done => "this is a test",
  },
  {
    name => "backspace",
    step => [
      "this is a test",
      "\cH\cH\cH\cH",   # backward-delete-char
      "TEST",
      "\cA",            # beginning-of-line
      "\cA",            # beginning-of-line (fail bol)
      "\cD",            # delete-char
      "T",
      "\cJ",
    ],
    done => "This is a TEST",
  },
  {
    name => "forward/backward",
    step => [
      "one three five",
      "\cA",            # beginning-of-line
      "\cB",            # backward-char (fail bol)
      "\eb",            # backward-word (fail bol)
      "\cH",            # backward-delete-char (fail bol)
      "\cF\cF\cF two",  # forward-char
      "\cE",            # end-of-line
      "\cE",            # end-of-line (fail eol)
      "\cF",            # forward-char (fail eol)
      "\cD",            # delete-char (fail eol)
      "\ef",            # forward-word (fail eol)
      "\cB\cB\cB\cB",   # backward-char
      "four \cJ",
    ],
    done => "one two three four five",
  },
  {
    name => "delete words",
    step => [
      "one two three",
      "\ed",            # kill-word (fail eol)
      "\e\cH",          # backward-kill-word
      "four",
      "\cA",
      "\e\cH",          # backward-kill-word (fail bol)
      "\ed",            # kill-word
      "\cJ",
    ],
    done => "two four",
  },
  {
    name => "case changes",
    step => [
      "LOWER upper other",
      "\cA\cF\cF\cF",
      "\el",            # downcase-word
      "\ef",            # forward-word
      "\eb",            # backward-word
      "\cF\cF\cF",
      "\eu",            # upcase-word
      "\ec",            # capitalize-word
      "\cE",
      "\el",            # downcase-word (fail eol)
      "\eu",            # upcase-word (fail eol)
      "\ec",            # capitalize-word (fail eol)
      "\cJ",
    ],
    done => "LOWer uppER Other",
  },
  {
    name => "transpose",
    step => [
      "one two 12",
      "\cb",
      "\cT",            # transpose-chars
      "\eb\eb",
      "\et",            # transpose-words
      "\cA\cT",         # transpose-chars (fail bol)
      "\cE12\cT",       # transpose-chars (at eol
      "\cJ",
    ],
    done => "two one 2121",
  },
);

sub POE::Kernel::CATCH_EXCEPTIONS () { 0 }
sub POE::Kernel::ASSERT_DEFAULT () { 1 }

BEGIN {
  package
  POE::Kernel;
  use constant TRACE_DEFAULT => exists($INC{'Devel/Cover.pm'});
}

use Test::More;

# There are some reasons not to run this test.

BEGIN {
  my $error;
  if ($^O eq "MSWin32" and not $ENV{POE_DANTIC}) {
    $error = "$^O cannot multiplex terminals";
  }
  elsif (!-t STDIN) {
    $error = "not running in a terminal";
  }
  else {
    eval "use Term::ReadKey";
    if ($@) {
      $error = "This test requires Term::ReadKey";
    }
    else {
      eval "use IO::Pty";
      if ($@) {
        $error = "This test requires IO::Pty";
      }
      else {
        eval "use Term::Cap";
        if ($@) {
          $error = "This test requires Term::Cap";
        }
      }
    }
  }

  unless ($error) {
    use POSIX ();

    my $termios = POSIX::Termios->new();
    $termios->getattr();
    my $ospeed = $termios->getospeed() || eval { POSIX::B38400() } || 0;

    $ENV{TERM} = "vt100" if $^O eq "solaris" or not $ENV{TERM};

    my $termcap = eval {
      Term::Cap->Tgetent( { TERM => $ENV{TERM}, OSPEED => $ospeed } )
    };
    unless ($termcap) {
      $error = "Term::Cap failure: $@";
      $error =~ s/ at \S+ line \d+.*//;
      $error =~ s/\s+/ /g;
    }
  }

  if ($error) {
    plan skip_all => $error;
    CORE::exit();
  }
}

use Symbol qw(gensym);
use POSIX qw(
  sysconf setsid _SC_OPEN_MAX ECHO ICANON IEXTEN ISIG BRKINT ICRNL
  INPCK ISTRIP IXON CSIZE PARENB OPOST TCSANOW
);

# Redirection must be done before POE::Wheel::ReadLine is loaded,
# otherwise it grabs copies of STDIN and STDOUT.

my ($saved_stdin, $saved_stdout, $pty_master, $pty_slave);
BEGIN {
  # Redirect STDIN and STDOUT to temporary handles for the duration of
  # this test.

  $saved_stdin = gensym();
  open($saved_stdin, "<&STDIN") or die "can't save stdin: $!";
  $saved_stdout = gensym();
  open($saved_stdout, ">&STDOUT") or die "can't save stdout: $!";

  # Create a couple one-way pipes for our new stdin and stdout.

  $pty_master = IO::Pty->new() or die "pty: $!";
  select $pty_master; $| = 1;

  $pty_slave = $pty_master->slave();

  # Put the pty conduit (slave side) into "raw" or "cbreak" mode,
  # per APITUE 19.4 and 11.10.

  my $tio = POSIX::Termios->new();
  $tio->getattr(fileno($pty_slave));
  my $lflag = $tio->getlflag;
  $lflag &= ~(ECHO | ICANON | IEXTEN | ISIG);
  $tio->setlflag($lflag);
  my $iflag = $tio->getiflag;
  $iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON);
  $tio->setiflag($iflag);
  my $cflag = $tio->getcflag;
  $cflag &= ~(CSIZE | PARENB);
  $tio->setcflag($cflag);
  my $oflag = $tio->getoflag;
  $oflag &= ~(OPOST);
  $tio->setoflag($oflag);
  $tio->setattr(fileno($pty_slave), TCSANOW);

  select $pty_slave; $| = 1;

  # Redirect our STDIN and STDOUT to the pipes.

  open(STDIN, "<&=" . fileno($pty_slave)) or die "stdin pipe redir: $!";
  open(STDOUT, ">&=" . fileno($pty_slave)) or die "stdout pipe redir: $!";
  select STDOUT; $| = 1;
}

# Restore the original stdio at the end of the run.

END {
  if ($saved_stdin) {
    open(STDIN, "<&=" . fileno($saved_stdin)) or die "stdin restore: $!";
    $saved_stdin = undef;
  }

  if ($saved_stdout) {
    open(STDOUT, ">&=" . fileno($saved_stdout)) or die "stdout restore: $!";
    $saved_stdout = undef;
  }
}

use POE qw(Filter::Stream Wheel::ReadWrite);

eval "use POE::Wheel::ReadLine";
if ($@ and $@ =~ /(requires a termcap|failed termcap lookup|cannot run)/) {
  my $error = $@;
  $error =~ s/ at \S+ line \d+.*//s;
  $error =~ s/\s+/ /g;
  plan skip_all => $error;
}

plan tests => scalar(@tests);

### Session to run the tests.

POE::Session->create(
  inline_states => {
    _start                => \&test_start,
    got_readwrite_output  => \&test_readwrite_output,
    got_readline_input    => \&test_readline_input,
    start_next_test       => \&test_start_next,
    step_this_test        => \&test_step,
    _stop                 => sub { },
  },
);

### Main loop.

POE::Kernel->run();

### The rest of this code is event handlers.

sub test_start {
  my ($kernel, $heap) = @_[KERNEL, HEAP];

  # The ReadLine wheel to drive and test.

  eval {
    $heap->{readline} = POE::Wheel::ReadLine->new(
      InputEvent => "got_readline_input",
      appname => "my_cli",
    );
  };

  if ($@) {
    my $error = $@;
    $error =~ s/ at \S+ line \d+.*//s;
    $error =~ s/\s+/ /g;
    plan skip_all => $error;
    return;
  }

  unless ($heap->{readline}) {
    plan skip_all => "POE::Wheel::Readline->new failed (term=$ENV{TERM})";
    return;
  }

  # Create a Wheel::ReadWrite to work on the driving side of the
  # pipes.

  $heap->{readwrite} = POE::Wheel::ReadWrite->new(
    Handle => $pty_master,
    Filter => POE::Filter::Stream->new(),
    InputEvent => "got_readwrite_output",
  );


  # And start testing.

  $kernel->yield("start_next_test");
}

sub test_readwrite_output {
  my ($heap, $input) = @_[HEAP, ARG0];
  if (DEBUG) {
    $input =~ s/[\x0A\x0D]+/{ENTER}/g;
    warn "$heap->{test}{name} - got output from child ($input)\n";
  }
}

sub test_readline_input {
  my ($kernel, $heap, $input) = @_[KERNEL, HEAP, ARG0];

  my $test = $heap->{test};
  my $name = $test->{name};

  DEBUG and warn "$name - got readline input ($input)\n";

  if (@{$test->{step}}) {
    fail("$name - got test input prematurely");
  }
  else {
    is( $input, $test->{done}, $name );
  }

  $kernel->yield("start_next_test");
}

sub test_start_next {
  my ($kernel, $heap) = @_[KERNEL, HEAP];

  if (@tests) {
    $heap->{test} = shift @tests;
    $kernel->yield("step_this_test");
    $heap->{readline}->get("next step");

    # Second get to instrument a test for this sort of thing.
    $heap->{readline}->get("next step");
    return;
  }

  # Delete them first so that we can tell the relative order of
  # destruction when DEBUG is turned on.
  delete $heap->{readline};
  delete $heap->{readwrite};
  DEBUG and warn "Done with all tests.\n";
  return;
}

sub test_step {
  my ($kernel, $heap) = @_[KERNEL, HEAP];

  my $next_step = shift @{$heap->{test}{step}};
  unless ($next_step) {
    DEBUG and warn "$heap->{test}{name} - done with test\n";
    return;
  }

  if (DEBUG) {
    my $output_next_step = $next_step;
    $output_next_step =~ s/[\x00-\x1F\x7F]/./g;
    warn "$heap->{test}{name} - typing ($output_next_step)\n";
  }

  $heap->{readwrite}->put($next_step);
  $kernel->yield("step_this_test");
}

1;

__END__

TODO: These are emacs key bindings for things we haven't yet tested.

C-]: character-search
C-_: undo
C-c: interrupt
C-g: abort
C-i: complete
C-k: kill-line
C-l: clear-screen
C-n: next-history
C-p: previous-history
C-q: poe-wheel-debug
C-r: reverse-search-history
C-s: forward-search-history
C-u: unix-line-discard
C-v: quoted-insert
C-w: unix-word-rubout
C-xC-g: abort
C-xC-r: re-read-init-file
C-xDel: backward-kill-line
C-xk: dump-key
C-xm: dump-macros
C-xv: dump-variables
C-y: yank
M-#: insert-comment
M-&: tilde-expand
M-*: insert-completions
M--: digit-argument
M-.: yank-last-arg
M-0: digit-argument
M-1: digit-argument
M-2: digit-argument
M-3: digit-argument
M-4: digit-argument
M-5: digit-argument
M-6: digit-argument
M-7: digit-argument
M-8: digit-argument
M-9: digit-argument
M-<: beginning-of-history
M->: end-of-history
M-?: possible-completions
M-C-[: complete
M-C-]: character-search-backward
M-C-g: abort
M-C-i: tab-insert
M-C-j: vi-editing-mode
M-C-r: revert-line
M-C-y: yank-nth-arg
M-\: delete-horizontal-space
M-_: yank-last-arg
M-n: non-incremental-forward-search-history
M-p: non-incremental-reverse-search-history
M-r: revert-line
M-space: set-mark
M-y: yank-pop
M-~: tilde-expand
down: next-history
ins: overwrite-mode
up: previous-history