package org.gregorie.runtest;

import java.util.*;
import java.io.*;

/**
 * <p><b>RunTest</b> is a customisable, scriptable test harness for
 * exercising Java classes in a realistic fashion. It reads linear
 * scripts that specify sequences of class instantiations followed by
 * sequences of method calls. Script lines are output followed, where
 * relevant, by a 'note' line, a 'result' line and/or an error line.
 * Error lines are counted and the number of errors found is reported
 * at the end of the run.</p>
 * 
 * <p>The resulting test report is intended to be both readable and to
 * enable regression testing by comparing this output with a previously
 * saved copy from a previous test run.</p>
 *
 * <p>A script consists of comments and action lines. Comments start with
 * a '#' as the first character on the line and extend to its end.
 * Comments and blank lines are output as part of the test report but
 * are otherwise ignored.</p>
 *
 * <p>Action lines consist of a verb, which identifies the action to be
 * carried out, and is followed by zero or more arguments. The verb and
 * arguments are separated by any combination of spaces and tabs.
 * Arguments may contain whitespace but must be enclosed in single or
 * double quotemarks to protect the enclosed whitespace. The same
 * quotemark must be used to start and end a quoted argument: this allows
 * quoted arguments to be test that a method can strip quotes from an
 * argument: "'x'" will pass the argument to the method as 'x' and
 * '"text"' will be passed to the method as "text".
 *
 * A comment, preceeded by a ' #' or '\t#', may be added to the end of an
 * action line. The requirement for whitespace in front of the '#' is
 * so that hash signs can be used as data provided that they are not
 * preceeded by whitespace, i.e. have been quote protected.</p>
 *
 * <p><b>RunTest</b> accepts -d (debug), -n (nowait) and -? (help) options.</p>
 *
 * <p>-d may occur multiple times and sets the trace level in the ReportError
 * class, which is used to handle all output and may also be passed to the
 * class(es) under test. The default setting causes script lines to be
 * output, interspersed with method call results and error indications.
 * '-d' also shows the result of parsing an action line and is output
 * after the action line and before the associated test action is run.</p>
 *
 * <p>The '-n' option affects the operation of the <i>wait(n)</i> method,
 * which can be called by a Custom subclass to pause a script for 'n'
 * seconds. This option causes <i>wait()</i> to return without any delay:
 * the idea is that <i>wait()</i> can be used in test scripts to allow the
 * developer to introduce pauses so that test scripts can be visually
 * checked, but during regression tests this option will be used to allow
 * the script to run at its maximum speed.</p>
 *
 * <p>The '-?' option causes the showHelp() method to be called and the
 * run to terminate. This should be overridden by any class that extends
 * Custom.</p>
 *
 * <p>RunTest's actions can be summarised as:</p>
 * <ul>
 *  <li>Accept options, the name of the Custom subclass used for
 *      this test run and script name(s) from the command line.</li>
 *  <li>Load the requested Custom subclass.
 *      Custom subclasses provide two important functions:
 *      <ul>
 *        <li>Provide an interface between RunTest and the class being
 *            tested.</li> 
 *        <li>Interpret the parsed content of a script action line
 *            by associating <i>fn</i> (the first word in the action line),
 *            with a method (the name usually matches <i>fn</i>),
 *            passing the list of arguments to the method and inserting
 *            any results it returns or errors it signals into the output
 *            stream of script text.</li>
 *      </ul>
 *  </li>
 *  <li>Call the Custom.initialiseTest() method.</li>
 *  <li>Open and process the script:
 *      <ul>
 *        <li>All script lines are sent to the output stream</li>
 *        <li>Comments are skipped. Action lines are parsed.</li>
 *        <li>Call the Custom.executeTest() method for every action
 *            line, passing it <i>fn</i> and the argument list.</li>
 *      </ul>
 *  <li>Close the script.</li>
 *  <li>Call the Custom.finishTest() method.</li>
 *  <li>Output the error count.</li>
 * </ul>
 *
 * <p>A test is run with the command:</p>
 *
 * <blockquote>
 * java org.gregorie.runtest.RunTest TESTHARNESS [options] script...
 * </blockquote>
 *
 * <p>where TESTHARNESS is the name of the Custom subclass that
 *    interfaces the class(es) under test to RunTest, the options are
 *    -?, -d and -n and one or more scripts specify tests to be run in
 *    sequence.</p>
 *
 * <p>If TESTHARNESS is a simple classname, it is assumed to be a member
 * of the 'testprogs' package, so RunTest will attempt to load and run
 * the testprogs.TESTHARNESS class. If the name contains one or more
 * fullstops it is assumed to be a complete, fully qualified class name,
 * e.g. by specifying org.gregorie.runtest.Custom you can override
 * the default package name and execute the Custom superclass
 * directly.</p>
 *<pre>
 *             Written by M.C.Gregorie.
 *
 *             Version history
 *             
 *             1.0   06/11/11   Initial release
 *             1.1   09/11/11   Added setNote() method
 *                              setResult() now aligns multiline results. 
 *             1.2   09/06/12   Overloaded setResult for boolean,
 *                              char and int arguments.
 *                              Added the setResultWithHex(), wait() methods.
 *                              Added the -n option.
 *             1.3   11/07/12   Comments at the end of lines must be preceeded
 *                              by whitespace.
 *             2.0   13/07/20   Test arguments may now include whitespace
 *                              if quoted by "..." or '...'. 
 *             2.1   26/07/20   Removed all dependency on environ.jar
 *</pre>
 * <PRE>
 * <b>Licensing</b>
 * 
 * Java support classes
 * Copyright (C) 2020  Martin C Gregorie
 * 
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the Lesser GNU General Public License
 * as published by the Free Software Foundation; either version 2
 * of the License, or (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * Lesser GNU General Public License for more details.
 * 
 * You should have received a copy of the Lesser GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
 * </PRE>
 * <p>Contact Martin Gregorie at
 * <a href="mailto:martin@gregorie.org">martin@gregorie.org</a>.<br>
 * Read the <a href="http://www.gnu.org/licenses/lgpl.html">Lesser GNU
 * General Public License</a>.</p>
 */

public class RunTest
{
   private static final String   VERSION     = "2.1";
   private static final String   COPYRIGHT   = "Copyright M.C.Gregorie, 2020";
   private static final String   PACKAGE     = "testprogs";
   private static final String   PROGNAME    = "RunTest";

   private int                   debug = 0;
   private boolean               help = false;
   private boolean               waitMode = true;             
   private ArrayList<String>     scripts = new ArrayList<String>();
   private String                customName = null;
   private Custom                custom = null;

   private String                fn = null;
   private ArrayList<String>     p  = null;
   private ArrayList<Integer>    n  = null;

   private int                   errorCount = 0;
   private ArrayList<String>     errorList = new ArrayList<String>();
   private String                note = null;
   private String                result = null;

   public static void main(String[] args)
   {
      RunTest rt = new RunTest();
      rt.run(args);
   }

   /**
    * This method is run when the '-?' option is present.
    * It should be overridden by class-specific text in Custom subclasses.
    */
   public void showHelp()
   {
      trace("\nRunTest " + VERSION + " " + COPYRIGHT + "\n");
      trace("Syntax  : java " + PACKAGE + "." + PROGNAME +
            " TESTNAME [<opts>] script...");
      trace("Function: RunTest provides the facilities");
      trace("          needed to script class unit testing and");
      trace("          regression testing. TESTNAME identifies");
      trace("          the Custom extension that provides");
      trace("          the interface between RunTest and the");
      trace("          class being tested. Hence it identifies");
      trace("          the class being tested and converts action");
      trace("          lines in the script into method calls for");
      trace("          the target class.");
      trace("Options :");
      trace("     -d   debug mode if given. Multiple use shows more");
      trace("          debugging detail. Level 1 causes the result of");
      trace("          parsing script action lines to be traced");
      trace("     -n   disables wait requests.");
      trace("     -?   '-?' shows help for the currently selected");
      trace("          Custom if it was specified and loaded.");
      trace("          Otherwise RunTest help is shown.");
   }

   /**
    * <p>Output an error message to <i>System.err</i> and abandon the run.
    *
    * <p>The message has the form "progname: error message"  and is
    * terminated with a newline.</p>
    *
    * @param eText The error message
    */
   public void error(String eText)
   {
     System.err.println(PROGNAME + ": " + eText);
     System.exit(1);
   }

   /**
    * @return Return the current tracing debug level
    */
   public int getDebugLevel()
   {
      return debug;
   }
                                          
   /**
    * Return a string with non-printable ASCII characters replaced by 
    * strings of the form &lt;hh&gt; where hh are two hexadecimal
    * digits.
    * @param s A string which mat contain non-printable characters
    * @return  The string with nonprintable characters replaced by
    8          hexadecimal representations.
    */
   public String getHex(String s)
   {
      StringBuffer   e = new StringBuffer();

      if (s == null)
      {
         e.append("(null)");
      }
      else
      {
         for (int i = 0; i < s.length(); i++)
         {
            Character   c = new Character(s.charAt(i));
            int         n = (byte)s.charAt(i);

            if (Character.isISOControl(c.charValue()) || n < 0)
            {
               String x = null;
               String  h = new String(Integer.toHexString(c.charValue()));
               if (h.length() == 1)
                  x = new String("<0" + h.toUpperCase() + ">");
               else
                  x = new String("<" + h.toUpperCase() + ">");
               e.append(x);
            }
            else
            {
               e.append(s.charAt(i));
            }
         }
      }

      return e.toString();
   }

   /**
    * Test if the debug level is high enough for the diagnostic
    * code it controls to be run
    * @param level Debug must be equal to or greater than level for the
    *              message to be output.
    * @return      True if the debug level is at least the same as level.
    *              Otherwise false is returned.
    */
   public boolean isTraceable(int level)
   {
      return (debug >= level);
   }             

   /**
    * Output a trace message to <i>System.err</i>.
    * The message is terminated with a newline.
    * @param eText The trace message.
    */
   public void trace(String eText)
   {
      System.err.println(eText);
   }       

   /**
    * Output a trace message to <i>System.err</i> if <i>level</i> is
    * greater than or equal to the debug value.
    * The message is terminated with a newline.
    * @param level Debug must be equal to or greater than level for the
    *              message to be output.
    * @param eText The trace message.
    */
   public void trace(int level, String eText)
   {
      if (debug >= level)
         System.err.println(eText);
   }       

   /*
    * Methods called by executeTest() start here
    */

   /**
    * Used by executeTest to determine if an error has already
    * been reported.
    * @return true if an error was reported.
    */
   public boolean isError()
   {
      return (errorList.size() > 0);
   }

   /**
    * Called by executeTest() to report an error and increment the
    * error count for the script. The output consists of the
    * prefix "    Error: " followed by the error text.
    * @param e The error text.
    */
   public void setError(String e)
   {
      errorList.add(e);
      errorCount++;
   }

   /**
    * Called by executeTest() to output a note line.  The output
    * consists of the prefix "    Note: " followed by the
    * note text.
    @param n The note text.
    */
   public void setNote(String n)
   {
      note = n;
   }

   /**
    * <p>Called by executeTest() to output an boolean result.</p>
    *
    * <p>The output consists of the prefix "    Result: "
    * followed by 'true' or 'false'.</p>
    * @param b The boolean result.
    */
   public void setResult(boolean b)
   {
      Boolean val = new Boolean(b);
      result = val.toString();
   }

   /**
    * <p>Called by executeTest() to output a char result.</p>
    *
    * <p>The output consists of the prefix "    Result: "
    * followed by the char value enclosed in single quotes. If the
    * char is not printable, it is shown as hex enclosed in angle brackets.</p>
    *
    * @param c The character result.
    */
   public void setResult(char c)
   {
      StringBuilder sb = new StringBuilder();
      if (Character.isValidCodePoint(c))
         sb.append(c);
      else
         sb.append(0);
         
      result = getHex(sb.toString());
   }

   /**
    * <p>Called by executeTest() to output a double result.</p>
    *
    * <p>The output consists of the prefix "    Result: "
    * followed by the result value enclosed in single quotes.</p>
    *
    * @param d The double result
    */
   public void setResult(double d)
   {
      Double dd = new Double(d);
      result = dd.toString();
   }

   /**
    * <p>Called by executeTest() to output an int result.</p>
    *
    * <p>The output consists of the prefix "    Result: "
    * followed by the result value enclosed in single quotes.</p>
    *
    * @param n The integer result
    */
   public void setResult(int n)
   {
      Integer i = new Integer(n);
      result = i.toString();
   }

   /**
    * <p>Called by executeTest() to output a String result.</p>
    *
    * <p>The output consists of the prefix "    Result: "
    * followed by the result text enclosed in single quotes.
    * If the result text contains multiple lines these are spacefilled
    * on the left to align them with the first line of result text,
    * i.e. they all start just to the right of the 'Result:' heading.</p>
    *
    * @param r The result text.
    */
   public void setResult(String r)
   {
      result = r.replaceAll("\n", "\n              ");
   }

   /**
    * <p>Called by executeTest() to output a String result which
    * may contain non-printable characters.</p>
    *
    * <p>The output consists of the prefix "    Result: "
    * followed by the result text enclosed in single quotes.
    * If the result text contains multiple lines these are spacefilled
    * on the left to align them with the first line of result text,
    * i.e. they all start just to the right of the 'Result:' heading.</p>
    *
    * @param r A result String that may contain unprintable characters
    */
   public void setResultWithHex(String r)
   {
      String s = new String(getHex(r));
      result = s.replaceAll("\n", "\n              ");
   }

   /**
    * <p>Pause for the requested number of seconds.
    * If RunTest is executed with the '-n' command line option,
    * it is running in <i>nowait</i> mode and this method returns
    * without waiting the requested number of seconds.</p>
    *
    * @param secs Number of seconds to wait
    */
   public void wait(int secs)
   {
      errorList.clear();
      if (waitMode)
      {
         try
         {
            Thread.sleep(secs * 1000);
         }
         catch (InterruptedException e)
         {
            error("Wait.millis(" + n + ") failed: " + e.getMessage());
         }                        
      }
   }
   
   /*
    * Private methods that provide the test infrastructure start here
    */

   /**
      This function scans the command line for options and does
      all the associated setup.
      @param args The command line arguments
   */
   private void readArgs(String args[])
   {
      String arg = null;

      for (int i = 0; i < args.length; i++)
      {
         arg = args[i];
         if (arg == null)
            break;

         if (arg.startsWith("-"))
         {
            for (int j = 1; j < arg.length(); j++)
            {
               char opt = arg.charAt(j);
               if (opt == '?')
                  help = true;
               else
               if (opt == 'd')
                  debug++;
               else
               if (opt == 'n')
                  waitMode = false;
               else
                  error("invalid option: '" + opt + "'");
            }
         }
         else
         {
            /*
               The first non-option is the Custom class name.
               The rest are script names.
            */
            if (customName == null)
               customName = new String(arg);
            else
               scripts.add(arg);
         }
      }

      if (isTraceable(1))
      {
         trace("Options:   -?=" + help + " -d=" + debug + " -n=" + waitMode);
         trace("Executing: " + customName);
         for (int i = 0; i < scripts.size(); i++)
            trace("Reading:   " + scripts.get(i));
      }
   }

   /**
    * This method does all the initial setup before running the
    * test(s).
    * @param args the command line arguments
    */
   private void run(String args[])
   {
      trace(2, "run() started");
      /*
         Parse and set up command options
      */
      readArgs(args);

      /*
         Load and initialise the required test harness class
      */
      if (customName != null)
      {
         try
         {
            /*
               customName defaults to HARNESS + customName
               but becomes simple customName if this contains '.'.
            */
            String fullHarnessName = PACKAGE + "." + customName;
            if (customName.contains("."))
               fullHarnessName  = customName;

            CustomLoader loader = new CustomLoader();
            Class             target = loader.loadClass(fullHarnessName);
            custom = (Custom)target.newInstance();      
            custom.link(this);
         }
         catch (Exception e)
         {
            error(e.toString());
         }
      }

      trace(3, "Linked up: custom=" + custom);

      /*
         If help was asked for, give it and quit
      */
      if (help) 
      {
         if (custom == null || customName == null)
            this.showHelp();
         else
            custom.showHelp();
            
         System.exit(0);
      }

      for (int i = 0; i < scripts.size(); i++)
      {
         trace(1, "Running script " + scripts.get(i));
         if (!runTest((String)scripts.get(i)))
            break;
      }
   }

   /**
    * Process a complete script
    * @param script Name of the script file
    * @return false if an IOException occurred and true otherwise
    */
   private boolean runTest(String script)
   {
      File                 f = null;
      FileReader           fr = null;
      BufferedReader       br = null;

      int                  lineno = 0;
      String               line = null;
      int                  linelth = 0;
      boolean              scriptEnd = false;

      errorCount = 0;
      f = new File(script);
      if (!f.canRead())
      {
         error("Can't open " + script);
      }

      try
      {
         fr = new FileReader(f);
      }
      catch (FileNotFoundException e)
      {
         trace("FileNotFoundException " + e.getMessage() +
               " reading script " + script);
         return false;
      }
      
      br = new BufferedReader(fr);

      /*
         Carry out script-specific initialisation
      */
      custom.initialiseTest();

      /*
         This the the main loop which:
         - reads a script line
         - parses it
         - calls the executeTest() method to carry out the required action
         - reports results
      */
      while (!scriptEnd)
      {
         try
         {
            line = br.readLine();
         }
         catch (IOException e)
         {
            trace("IOException " + e.getMessage() +
                  " reading script " + script);
            return false;
         }

         scriptEnd = (line == null);
         if (scriptEnd)
            break;
            
         /*
            Count lines read.
            Print the line.
         */
         linelth = line.length();
         lineno++;
         trace(String.format("%3d: %s", lineno, line));

         /*
            Ignore blank lines and those starting with #
         */
         if (isBlank(line) || line.charAt(0) == '#')
            continue;

         /*
            If the line has a trailing comment, remove it
            provided there's a space or tab on front of the '#' -
            This allows '#' to be used as data provided its quoted
         */
         int x = line.lastIndexOf('#');
         if (x > 0 &&
             (line.charAt(x - 1) == ' ' || line.charAt(x - 1) == '\t'))
         {
            line = line.substring(0, x);
            linelth = line.length();
            if (debug > 2)
            {
               trace("Line truncated to '" + line + "'");
            }
         }

         /*
            Start by clearing errors returned by exceptions
            and the results area and setting 'success' to true,
            to show that no errors have been reported.
            
            Parse the action line.
         */
         errorList.clear();
         note = null;
         result = null;
         parseLine(lineno, line);

         /*
            Call callAdapter to carry out the action requested
            by an action line. It returns true on success and false
            if a fatal error ocurred.
         */
         try
         {
            custom.callAdapter(fn, p, n);
         }
         catch(IndexOutOfBoundsException e)
         {
            setError("'" + fn + "' needs more arguments");  
         }

         /*
            If output was received, output it.
         */
         if (result != null)
            trace("     Result: '" + result + "'");
         
         if (note != null)
            trace("     Note: " + note);

         for (String e : errorList)
         {
            trace("     Error: " + e);
         }
      }

      /*
         Carry out any post-run cleanup thatmmay be needed/
      */
      custom.finishTest();

      /*
         Run complete
      */
      trace("Script ended: " + errorCount +
            (errorCount == 1 ? " error\n" : " errors\n"));

      try
      {
         br.close();
      }
      catch (IOException e)
      {
         trace("IOException " + e.getMessage() +
                  " closing script " + script);
         return false;
      }
      
      return true;
   }

   /**
    * Helper class - check that a string is empty or blank
    * @param s The String being tested
    * @return true if the String is empty or only contains spaces
    */
   static private boolean isBlank(String s)
   {
      for (int i = 0; i < s.length(); i++)
      {
         if (!Character.isWhitespace(s.charAt(i)))
            return false;
      }

      return true;
   }

   /**
    * Parse an action line, returning a count of any non-fatal errors
    * and leaving the verb in fn, the arguments in p and integer
    * argument equivalents in n.
    * @param lineno Line number of the script (used in the error report)
    * @param line   The script line to be parsed
    * @return       The non-fatal error count
    */
   private void parseLine(int lineno, String line)
   {
      final int      FN           = 0;
      final int      QUOTED       = 1;
      final int      SEPSCAN      = 2;
      final int      UNQUOTED     = 3;
      final String   statename[]  = { "FN", "QUOTED", "SEPSCAN", "UNQUOTED" };

      char           c;
      int            state       = FN;
      char           terminator  = 0;
      StringBuilder  value       = null;

      trace(2, "parseLine(" + lineno + "," + line + ") started");
      
      p = new ArrayList<String>();
      n = new ArrayList<Integer>();
      value = new StringBuilder();
      for (int i = 0; i < line.length(); i++)
      {
         c = line.charAt(i);
         trace(3, String.format("'%c' i=%d state=%s",
                                c, i, statename[state]));
         switch(state)
         {
            case FN:       if (Character.isWhitespace(c))
                           {
                              fn = value.toString();
                              state = SEPSCAN;
                           }
                           else
                              value.append(c);
                           
                           break;

            case SEPSCAN:  value = new StringBuilder();
                           if (c == '\'' || c == '"')
                           {
                              state = QUOTED;
                              terminator = c;
                           }
                           else
                           if (!Character.isWhitespace(c))
                           {
                              state = UNQUOTED;
                              value.append(c);
                           }

                           break;
                           
            case QUOTED:   if (c == terminator)
                           {
                              state = SEPSCAN;
                              p.add(value.toString());
                           }
                           else
                              value.append(c);

                           break;

            case UNQUOTED: if (Character.isWhitespace(c))
                           {
                              state = SEPSCAN;
                              p.add(value.toString());
                           }
                           else
                              value.append(c);

                           break;

            default:       setError(String.format(
                                    "Unknown state at line %d char %d: '%c'",
                                    lineno, i, c));
         }
      }

      if (state == FN)
         fn = value.toString();
         
      if (state == UNQUOTED)
         p.add(value.toString());

      /*
         Now try to convert string parameters into integers.
      */
      trace(3, "Setting integers");

      for (int i = 0; i < p.size(); i++)
      {
         String   x = p.get(i);

         if (x.length() == 0)
            n.add(0);
         else
         {            
            c = p.get(i).charAt(0);
            if (c == 'T' || c == 't' || c == '1')
               n.add(1);
            else      
            if (c == 'F' || c == 'f' || c == '0')
               n.add(0);
            else
            {
               try
               {
                  n.add(Integer.parseInt(p.get(i)));
               }
               catch(NumberFormatException e)
               {
                  n.add(0);
               }
            }
         }
      }

      if (isTraceable(1))
      {
         StringBuilder list = new StringBuilder();
         list.append(fn + ":\n  :");
         for (int i = 0; i < p.size(); i++)
            list.append((i == 0 ? "" : " ,") + p.get(i));

         list.append("\n  :");
         for (int i = 0; i < p.size(); i++)
            list.append((i == 0 ? "" : " ,") + n.get(i));
            
         list.append("\n");
         trace(list.toString());
      }

      trace(2, "parseLine(" + lineno + "," + line + ") finished");
   }
}
