ActionReporter.java

package com.github.dakusui.actionunit.visitors;

import com.github.dakusui.actionunit.actions.Composite;
import com.github.dakusui.actionunit.core.Action;
import com.github.dakusui.actionunit.io.Writer;

import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;

import static java.lang.String.format;
import static java.util.Objects.requireNonNull;

/**
 * A basic action reporter which writes an action tree and its report to given writers
 *
 * @see ReportingActionPerformer
 */
public class ActionReporter extends ActionPrinter {
  public static final Predicate<Action>   DEFAULT_CONDITION_TO_SQUASH_ACTION = v -> v instanceof Composite;
  private final       List<Boolean>       failingContext                     = new LinkedList<>();
  private final       Predicate<Action>   conditionToSquashAction;
  private             int                 emptyLevel                         = 0;
  private             int                 depth                              = 0;
  private final       Map<Action, Record> report;
  private final       Writer              warnWriter;
  private final       Writer              traceWriter;
  private final       Writer              debugWriter;
  private final       Writer              infoWriter;
  private final       int                 forcePrintLevelForUnexercisedActions;
  String previousIndent = "";

  /**
   * Creates an object of this class.
   * This reporter implements a functionality to squash actions to make output concise.
   * It is controlled by a predicate given through `conditionToSquashAction` parameter.
   * Also, depending on whether an action is exercised or not, a writer used to print it out will be chosen by internal logic of this class.
   * Furthermore, actions in a deep level of a tree will be considered "unexercised" forcibly.
   * The threshold level can be controlled by the parameter `forcePrintLevelForUnexercisedActions`.
   *
   * @param conditionToSquashAction              A condition to "squash" a record of an action to make report concise.
   * @param warnWriter                           A writer used for "warn" level.
   * @param infoWriter                           A writer used for "info" level.
   * @param debugWriter                          A writer used for "debug" level.
   * @param traceWriter                          A writer used for "trace" level.
   * @param report                               A map that stores an action tree's result.
   * @param forcePrintLevelForUnexercisedActions A level under which action should be considered to be "unexercised".
   */
  public ActionReporter(Predicate<Action> conditionToSquashAction, Writer warnWriter, Writer infoWriter, Writer debugWriter, Writer traceWriter, Map<Action, Record> report, int forcePrintLevelForUnexercisedActions) {
    super(infoWriter);
    this.conditionToSquashAction = conditionToSquashAction;
    this.report = requireNonNull(report);
    this.warnWriter = requireNonNull(warnWriter);
    this.debugWriter = requireNonNull(debugWriter);
    this.infoWriter = infoWriter;
    this.traceWriter = traceWriter;
    this.forcePrintLevelForUnexercisedActions = forcePrintLevelForUnexercisedActions;
  }

  /**
   * Creates an action of this class.
   *
   * @param writer A writer through which a report is written.
   * @param report A report data that records the result of actions in the tree.
   */
  public ActionReporter(Writer writer, Map<Action, Record> report) {
    this(DEFAULT_CONDITION_TO_SQUASH_ACTION, writer, writer, writer, writer, report, 2);
  }

  /**
   * An entry-point to start reporting.
   *
   * @param action An action from which reporting starts.
   */
  public void report(Action action) {
    requireNonNull(action).accept(this);
  }

  @Override
  protected void handleAction(Action action) {
    if (this.conditionToSquashAction.test(action)) {
      this.previousIndent = indent();
      return;
    }
    Record runs = report.get(action);
    String message = format("%s[%s]%s", indent(), runs != null ? runs : "", action);
    this.previousIndent = "";
    if (isInFailingContext()) {
      this.warnWriter.writeLine(message);
    } else {
      if (emptyLevel < 1) { // Top level unexercised + exercised ones
        if (passingLevels() < 1) {
          this.infoWriter.writeLine(message);
        } else {
          writeLineForUnexercisedAction(message);
        }
      } else {
        writeLineForUnexercisedAction(message);
      }
    }
  }

  /**
   * //@formatter.off
   * ----
   * > [E:0]for each of (noname) parallely
   * >  +-[EE:0]print1
   * >    |   [EE:0](noname)
   * >    +-[]print2
   * >      |   [](noname)
   * >      +-[]print2-1
   * >      |   [](noname)
   * >      +-[]print2-2
   * >        [](noname)
   * ----
   * //@format:on
   */
  @Override
  public String indent() {
    List<? extends Action> path = this.path();
    StringBuilder b = new StringBuilder();
    if (!path.isEmpty()) {
      Action last = path.get(path.size() - 1);
      for (Action each : path) {
        if (each instanceof Composite) {
          if (each == last) {
            if (((Composite) each).isParallel())
              b.append("*-");
            else
              b.append("+-");
          } else {
            if (isLastChild(nextOf(each, path), each))
              b.append("  ");
            else
              b.append("| ");
          }
        } else {
          b.append("  ");
        }
      }
    }
    return mergeStrings(this.previousIndent, b.toString());
  }

  @Override
  protected void enter(Action action) {
    super.enter(action);
    depth++;
    Record runs = report.get(action);
    pushFailingContext(runs != null && runs.allFailing());
    if (runs == null)
      emptyLevel++;

  }

  @Override
  protected void leave(Action action) {
    Record runs = report.get(action);
    if (runs == null)
      emptyLevel--;
    popFailingContext();
    depth--;
    super.leave(action);
  }

  boolean isInFailingContext() {
    return !this.failingContext.isEmpty() && this.failingContext.get(0);
  }

  void pushFailingContext(boolean newContext) {
    failingContext.add(0, newContext);
  }

  void popFailingContext() {
    failingContext.remove(0);
  }

  private void writeLineForUnexercisedAction(String message) {
    // unexercised
    if (depth < this.forcePrintLevelForUnexercisedActions)
      this.debugWriter.writeLine(message);
    else
      this.traceWriter.writeLine(message);
  }

  private int passingLevels() {
    int ret = 0;
    for (boolean each : this.failingContext)
      if (!each)
        ret++;
    return ret;
  }

  private static Action nextOf(Action each, List<? extends Action> path) {
    return path.get(path.indexOf(each) + 1);
  }

  /**
   * Merges two string into one.
   * A white space in `a` or `b` will be replaced with non-white space in the other at the same position.
   * In case both have non-white space in the same position, the latter's (`b`) overrides the first's (`a`).
   * <p>
   * .Example input
   * ----
   * a:"hello    "
   * b:"    O WORLD "
   * ----
   * <p>
   * .Example output
   * ----
   * "hellO WORLD "
   * ----
   *
   * @param a A string to be merged.
   * @param b A stringto be merged
   * @return The merged result string.
   */
  private static String mergeStrings(String a, String b) {
    StringBuilder builder = new StringBuilder();
    int min = Math.min(a.length(), b.length());
    for (int i = 0; i < min; i++) {
      char ach = a.charAt(i);
      char bch = b.charAt(i);
      if (bch != ' ')
        builder.append(bch);
      else
        builder.append(ach);
    }
    // Whichever longer, the result is the same since the shorter.substring(min) will become an empty
    // string.
    builder.append(a.substring(min));
    builder.append(b.substring(min));
    return builder.toString();
  }

  private static boolean isLastChild(Action each, Action parent) {
    if (parent instanceof Composite) {
      int index = ((Composite) parent).children().indexOf(each);
      int size = ((Composite) parent).children().size();
      assert index >= 0;
      return index == size - 1;
    }
    return true;
  }
}