aboutsummaryrefslogtreecommitdiffstats
path: root/src/main/scala/com/iinteractive/test/tap/TestBuilder.scala
blob: 62fdf02f081c292b21fd26800956d2ab8ddcb5bd (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
package com.iinteractive.test.tap

import com.iinteractive.test._

/** This class provides a convenient yet low level API for generating TAP
  * streams. Each instance of this class handles a single TAP stream, and
  * keeps track of things like the current test number for you. All
  * TAP-producing methods write the TAP lines to `Console.out` or
  * `Console.err`, so you can override those (via `Console.withOut` or
  * `Console.withErr`).
  */
class TestBuilder private (
  plan:          Plan,
  indent:        String,
  terminalInUse: Boolean
) {
  plan match {
    case NoPlan => ()
    case p      => outLine(Producer.plan(p))
  }

  /** Creates a new builder instance, and emits the corresponding plan line,
    * unless the plan is not given.
    *
    * @param plan [[com.iinteractive.test.Plan plan]] for this test.
    * @param terminalInUse Whether this test is being run from a harness which
    *                      will not just be writing directly to the output.
    *                      This will make things written to `Console.err` have
    *                      a newline prepended, so that they always start on
    *                      an empty line.
    */
  def this (plan: Plan = NoPlan, terminalInUse: Boolean = false) =
    this(plan, "", terminalInUse)

  /** Create a new TestBuilder instance, to be used to run a subtest. This new
    * instance will have all of its lines prefixed by an additional level of
    * indentation. This instance will still need to have `doneTesting`
    * called on it, and the result of the subtest will still need to be
    * reported as a separate test result through `ok`.
    */
  def cloneForSubtest (newPlan: Plan): TestBuilder =
    new TestBuilder(newPlan, indent + "    ", terminalInUse)

  /** Reports a single test result to `Console.out`. */
  def ok (test: Boolean) {
    state.ok(test)
    outLine(Producer.result(test, state.currentTest))
  }

  /** Reports a single test result with description to `Console.out`. */
  def ok (test: Boolean, description: String) {
    state.ok(test)
    outLine(Producer.result(test, state.currentTest, description))
  }

  /** Reports a single TODO test result to `Console.out`. */
  def todo (todo: String, test: Boolean) {
    state.ok(true)
    outLine(Producer.todoResult(test, state.currentTest, todo))
  }

  /** Reports a single TODO test result with description to `Console.out`. */
  def todo (todo: String, test: Boolean, description: String) {
    state.ok(true)
    outLine(Producer.todoResult(test, state.currentTest, description, todo))
  }

  /** Reports a single skipped test result to `Console.out`. */
  def skip (reason: String) {
    state.ok(true)
    outLine(Producer.skip(state.currentTest, reason))
  }

  /** Writes a comment line to `Console.err`. This will allow it to be
    * visible in most summarizing harnesses (which consume and parse
    * everything that goes to `Console.out`).
    */
  def diag (message: String) {
    errLine(Producer.comment(message))
  }

  /** Write a comment line to `Console.out`. This will typically only be
    * visible in the raw TAP stream.
    */
  def note (message: String) {
    outLine(Producer.comment(message))
  }

  /** Abort the current test, with a message. */
  def bailOut (message: String) {
    val bailOutMessage = Producer.bailOut(message)
    outLine(bailOutMessage)
    throw new BailOutException(bailOutMessage)
  }

  /** Finalize the current builder instance. This writes the auto-calculated
    * plan to `Console.out` if the plan type was `NoPlan` and reports a
    * summary of the test results as a comment to `Console.err`.
    *
    * @return whether or not the test class as a whole passed.
    */
  def doneTesting: Boolean = {
    plan match {
      case NumericPlan(_) => printErrors
      case SkipAll(_)     => ()
      case NoPlan         => {
        outLine(Producer.plan(state.currentTest))
        printErrors
      }
    }
    state.isPassing
  }

  /** The exit code to use, in harnesses that run a single test. Passing tests
    * return 0, invalid tests (such as running a different number of tests
    * than planned) return 255, and all others return the number of failed
    * tests.
    */
  def exitCode: Int =
    if (state.isPassing) {
      0
    }
    else if (!state.matchesPlan || state.currentTest == 0) {
      255
    }
    else {
      state.failCount
    }

  private def printErrors {
    if (!state.matchesPlan) {
      val planCount = (plan match {
        case NoPlan  => state.currentTest
        case p       => p.plan
      })
      val planned = planCount + " test" + (if (planCount > 1) "s" else "")
      val ran = state.currentTest
      diag("Looks like you planned " + planned + " but ran " + ran + ".")
    }

    if (state.currentTest == 0) {
      diag("No tests run!")
    }

    if (state.failCount > 0) {
      val count = state.failCount
      val fails = count + " test" + (if (count > 1) "s" else "")
      val total =
        state.currentTest + (if (state.matchesPlan) "" else " run")
      diag("Looks like you failed " + fails + " of " + total + ".")
    }
  }

  private val state = new TestState

  private def outLine (str: String) {
    Console.out.println(withIndent(str))
  }

  private def errLine (str: String) {
    if (terminalInUse) {
      Console.err.print("\n")
    }
    Console.err.println(withIndent(str))
  }

  private def withIndent (str: String): String =
    str.split("\n").map(s => indent + s).mkString("\n")

  private class TestState {
    var passCount = 0
    var failCount = 0

    def ok (cond: Boolean) {
      if (cond) {
        passCount += 1
      }
      else {
        failCount += 1
      }
    }

    def currentTest: Int =
      failCount + passCount

    def matchesPlan: Boolean = plan match {
      case NumericPlan(p) => p.plan == currentTest
      case _              => true
    }

    def isPassing: Boolean = plan match {
      case SkipAll(_) => true
      case _          => currentTest > 0 && failCount == 0 && matchesPlan
    }
  }
}