spinel_test-unit 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +45 -5
- data/lib/spinel_test/unit/cli.rb +80 -22
- data/lib/spinel_test/unit/diff.rb +159 -0
- data/lib/spinel_test/unit/generator.rb +9 -3
- data/lib/spinel_test/unit/runner.rb +82 -7
- data/lib/spinel_test/unit/scanner.rb +9 -5
- data/lib/spinel_test/unit/test_case.rb +91 -16
- data/lib/spinel_test/unit/version.rb +1 -1
- data/lib/spinel_test/unit.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ef62e948140f04c676b0709a5780239556c38dd521fbdd0fce4968f82b643fe4
|
|
4
|
+
data.tar.gz: 3212cc091c366bc33b3fc1854f3fbd6a4a164248baa20cdc3816d3896dcd8519
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9dcc12b871aa16d10cea18c038de5d335a4e07121098f3de6b3449be0020e82274d39593698b79a39457dd9049ccdb1fcc06c740458d536151dd592ba93b66e9
|
|
7
|
+
data.tar.gz: 588963b23dc23425e40721efad1070800e577aec65d9dbba959aa537f3055ddb0a06db18df1671ae484733d5d7055f2f0da8e102213cbc2598897a618be0e43b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
## Unreleased
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
* Add `match:` to `assert_raise` to check the exception message, as in `assert_match` (a Regexp, or a String meaning a substring)
|
|
6
|
+
* `assert_raise` passes only for the given class itself, not a subclass, as in test-unit. Use the new `assert_raise_kind_of` to also accept a subclass
|
|
7
|
+
* Add `-t`/`--testcase CLASS` to run only the tests of the class named exactly CLASS (`-n FooTest#` also selects `MyFooTest`)
|
|
8
|
+
* `-n` and `-t` take a regexp written as `/regexp/`, as in test-unit
|
|
9
|
+
|
|
10
|
+
## 0.2.0
|
|
11
|
+
|
|
12
|
+
* Report where a test failed: `at file:line` lines after the message (the test method's line with Spinel, the filtered backtrace with `--ruby`)
|
|
13
|
+
* Show a line diff when `assert_equal` fails on two multi-line Strings
|
|
14
|
+
* Add `assert_match`, `assert_not_equal`, `assert_not_nil`, `assert_include`, `assert_not_include` and `refute`. `assert_includes` remains as an alias of `assert_include`
|
|
15
|
+
* Add `--ruby` to run the tests with the current Ruby instead of compiling them with Spinel
|
|
16
|
+
|
|
3
17
|
## 0.1.2
|
|
4
18
|
|
|
5
19
|
* Exclude mise.toml from the gem
|
data/README.md
CHANGED
|
@@ -39,26 +39,65 @@ end
|
|
|
39
39
|
$ bundle exec spinel_test-unit # test/**/*_test.rb and test/**/test_*.rb
|
|
40
40
|
$ bundle exec spinel_test-unit test/calc_test.rb
|
|
41
41
|
$ bundle exec spinel_test-unit -n test_add # only tests whose "Class#method" name includes it
|
|
42
|
+
$ bundle exec spinel_test-unit -n '/#test_add\z/' # or matches the regexp
|
|
43
|
+
$ bundle exec spinel_test-unit -t CalcTest # only the tests of the class named exactly CalcTest
|
|
44
|
+
$ bundle exec spinel_test-unit -t '/\ACalc/' # or of the classes whose name matches the regexp
|
|
45
|
+
$ bundle exec spinel_test-unit --ruby # run the same tests with the current Ruby, no Spinel needed
|
|
42
46
|
```
|
|
43
47
|
|
|
44
|
-
|
|
48
|
+
`--ruby` runs the generated main.rb with the Ruby that runs the CLI, with the same load paths (`-I`, `lib`, `test`, the project root) instead of compiling it; the child also inherits `RUBYOPT` and `RUBYLIB` (e.g. `bundler/setup`), so it may resolve requires that Spinel cannot. Use it to check that code which must work under both CRuby and Spinel behaves the same in each. The same tests run, with the same report format and exit status convention, but results can differ where CRuby and Spinel differ: Spinel freezes string literals (so `s = "a"; s << "b"` raises under Spinel but passes under `--ruby`), and exception messages can be worded differently.
|
|
49
|
+
|
|
50
|
+
Output when compiled with Spinel:
|
|
45
51
|
|
|
46
52
|
```
|
|
47
|
-
|
|
53
|
+
.F
|
|
48
54
|
|
|
49
55
|
Failure: CalcTest#test_failure_message
|
|
50
56
|
1 + 1 should be 2
|
|
57
|
+
at test/calc_test.rb:14
|
|
58
|
+
|
|
59
|
+
2 tests, 2 assertions, 1 failures, 0 errors
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Each failure or error is `Failure: Class#test_name` or `Error: Class#test_name`, the message, and up to three ` at file:line` lines (paths relative to the project root):
|
|
63
|
+
|
|
64
|
+
- Compiled with Spinel, exceptions have no backtrace, so there is one line: the `file:line` of the test method's `def`.
|
|
65
|
+
- With `--ruby`, the lines are the backtrace entries outside this gem and the generated main.rb, so they point at the failing assertion in the test and the callers above it (a test helper, for example). The first entry is always kept, even from outside the project root (an exception raised inside another library shows where); after it only entries under the root are listed. A location equal to the previous line is left out (Ruby 4.0 attributes block-taking builtins such as `Array#each` to their caller's line). If nothing is left after filtering, it falls back to the test method's line.
|
|
66
|
+
- An error raised in `setup`, or in `teardown` when the test itself passed, is reported with the same lines (under Spinel, the test method's `def`).
|
|
67
|
+
- If a test method is defined more than once, the location of the last definition is used.
|
|
68
|
+
|
|
69
|
+
When `assert_equal` fails on two Strings that both contain a newline, a line diff follows the Expected/Actual lines. `-` lines are only in the expected string, `+` lines only in the actual one, and unchanged lines more than two away from a change are collapsed into `...`. A single hidden line is shown as is. Lines that could be misread (empty, containing `\r`, starting with `"`, equal to `...`, or starting or ending with a space or tab) are shown with `inspect` (`- ""`, `- "a\r"`). Example:
|
|
51
70
|
|
|
52
|
-
3 tests, 3 assertions, 1 failures, 0 errors
|
|
53
71
|
```
|
|
72
|
+
Failure: HttpTest#test_to_cgi
|
|
73
|
+
Expected: "a\nb\nc\nd\ne\nf"
|
|
74
|
+
Actual: "a\nb\nX\nd\ne\nf"
|
|
75
|
+
Diff:
|
|
76
|
+
a
|
|
77
|
+
b
|
|
78
|
+
- c
|
|
79
|
+
+ X
|
|
80
|
+
d
|
|
81
|
+
e
|
|
82
|
+
f
|
|
83
|
+
at test/http_test.rb:7
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Very large differences are not diffed: when the lines between the common start and end of the two strings would need a table of over a million cells, the diff is replaced by a one-line note and the Expected/Actual lines remain.
|
|
54
87
|
|
|
55
88
|
Available assertions:
|
|
56
89
|
|
|
57
90
|
- `assert(test, message = nil)`
|
|
58
91
|
- `assert_equal(expected, actual, message = nil)`
|
|
92
|
+
- `assert_not_equal(expected, actual, message = nil)`
|
|
59
93
|
- `assert_nil(value, message = nil)`
|
|
60
|
-
- `
|
|
61
|
-
- `
|
|
94
|
+
- `assert_not_nil(value, message = nil)`
|
|
95
|
+
- `assert_match(pattern, string, message = nil)` (`pattern` is a Regexp, or a String meaning a substring, and anything else is reported as an error; a `string` that is not a String, such as `nil`, fails)
|
|
96
|
+
- `assert_include(collection, item, message = nil)` (`assert_includes` is an alias)
|
|
97
|
+
- `assert_not_include(collection, item, message = nil)`
|
|
98
|
+
- `refute(test, message = nil)`
|
|
99
|
+
- `assert_raise(klass, match: nil) { ... }` (passes for `klass` itself, not a subclass, as in test-unit; returns the exception; assertion failures raised inside the block propagate). With `match:`, the exception's message must also match it, as in `assert_match` (a Regexp, or a String meaning a substring)
|
|
100
|
+
- `assert_raise_kind_of(klass, match: nil) { ... }` (as `assert_raise`, but also passes for a subclass of `klass`)
|
|
62
101
|
|
|
63
102
|
`setup` and `teardown` hooks are supported.
|
|
64
103
|
|
|
@@ -83,6 +122,7 @@ The tests are compiled for build speed, not run speed: with `-O0`, and the gener
|
|
|
83
122
|
- Files are loaded with `require` and the load paths `lib`, `test` and the
|
|
84
123
|
project root; use `require "..."` / `require_relative` that Spinel resolves.
|
|
85
124
|
- Test files under `fixtures/` directories (relative to the project root) are skipped unless named explicitly or the given directory is itself inside `fixtures/`.
|
|
125
|
+
- A method parameter is typed from all of its call sites, so mixing a nil-typed local and, say, an Integer in calls to the same assertion can fail to compile or raise at run time under Spinel. For example, `refute 0` together with `n = nil; refute n` in the test files compiled together fails at run time ("nil can't be coerced into Integer") while `--ruby` passes. Keep the arguments of one assertion to one type across those files.
|
|
86
126
|
- `ScriptError`s (such as `NotImplementedError`) raised by a test are reported as errors; a teardown error after a failure is appended to its message.
|
|
87
127
|
|
|
88
128
|
## Development
|
data/lib/spinel_test/unit/cli.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "open3"
|
|
5
|
+
require "rbconfig"
|
|
5
6
|
require "tmpdir"
|
|
6
7
|
require_relative "scanner"
|
|
7
8
|
require_relative "generator"
|
|
@@ -9,10 +10,12 @@ require_relative "toolchain"
|
|
|
9
10
|
|
|
10
11
|
module SpinelTest
|
|
11
12
|
module Unit
|
|
12
|
-
# spinel_test-unit [PATH...] [-n NAME]
|
|
13
|
+
# spinel_test-unit [PATH...] [-n NAME] [-t CLASS] [-I DIR] [--ruby]
|
|
13
14
|
#
|
|
14
15
|
# Scans the test files with Prism, generates a main.rb that calls every
|
|
15
|
-
# test, compiles it with Spinel and runs the binary.
|
|
16
|
+
# test, compiles it with Spinel and runs the binary. With --ruby it runs the
|
|
17
|
+
# generated main.rb with the current Ruby instead, so one test file can be
|
|
18
|
+
# checked under both.
|
|
16
19
|
class CLI
|
|
17
20
|
def self.start(argv, root: Dir.pwd)
|
|
18
21
|
new(argv, root: root).run
|
|
@@ -22,20 +25,27 @@ module SpinelTest
|
|
|
22
25
|
@root = File.expand_path(root)
|
|
23
26
|
@paths = []
|
|
24
27
|
@filter = nil
|
|
25
|
-
@
|
|
28
|
+
@testcase = nil
|
|
29
|
+
@ruby = false
|
|
26
30
|
@includes = []
|
|
27
31
|
@exit = nil
|
|
28
32
|
parse(argv.dup)
|
|
29
33
|
end
|
|
30
34
|
|
|
31
35
|
USAGE = <<~TEXT
|
|
32
|
-
Usage: spinel_test-unit [PATH...] [-n NAME] [-I DIR]
|
|
36
|
+
Usage: spinel_test-unit [PATH...] [-n NAME] [-t CLASS] [-I DIR] [--ruby]
|
|
33
37
|
|
|
34
|
-
Compiles the tests with Spinel and runs them
|
|
38
|
+
Compiles the tests with Spinel and runs them (with --ruby, runs them with the
|
|
39
|
+
current Ruby instead).
|
|
35
40
|
|
|
36
41
|
PATH test file or directory (default: test)
|
|
37
|
-
-n, --name NAME run only the tests whose "Class#test_name" includes NAME
|
|
38
|
-
|
|
42
|
+
-n, --name NAME run only the tests whose "Class#test_name" includes NAME,
|
|
43
|
+
or matches it when NAME is /regexp/
|
|
44
|
+
-t, --testcase CLASS
|
|
45
|
+
run only the tests of the class named CLASS (exact match),
|
|
46
|
+
or whose class name matches it when CLASS is /regexp/
|
|
47
|
+
-I DIR add DIR to the load path (repeatable)
|
|
48
|
+
--ruby run with the current Ruby instead of compiling with Spinel
|
|
39
49
|
-h, --help show this help
|
|
40
50
|
TEXT
|
|
41
51
|
|
|
@@ -48,10 +58,9 @@ module SpinelTest
|
|
|
48
58
|
return 1
|
|
49
59
|
end
|
|
50
60
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return 1
|
|
61
|
+
unless @ruby
|
|
62
|
+
spinel = find_spinel
|
|
63
|
+
return 1 unless spinel
|
|
55
64
|
end
|
|
56
65
|
|
|
57
66
|
entries = build_entries(files)
|
|
@@ -62,17 +71,38 @@ module SpinelTest
|
|
|
62
71
|
|
|
63
72
|
Dir.mktmpdir("spinel_test-unit") do |dir|
|
|
64
73
|
main = File.join(dir, "main.rb")
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return 1 unless compile(spinel, main, bin, entries)
|
|
68
|
-
|
|
69
|
-
args = @filter ? ["-n", @filter] : []
|
|
70
|
-
system(bin, *args) ? 0 : ($?.exitstatus || 1)
|
|
74
|
+
File.write(main, Generator.generate(entries, @root))
|
|
75
|
+
@ruby ? run_with_ruby(main, entries) : run_with_spinel(spinel, main, dir, entries)
|
|
71
76
|
end
|
|
72
77
|
end
|
|
73
78
|
|
|
74
79
|
private
|
|
75
80
|
|
|
81
|
+
def find_spinel
|
|
82
|
+
spinel = Toolchain.spinel
|
|
83
|
+
warn "spinel_test-unit: Spinel not found (set SPINEL, put spinel on PATH, or install it with mise)" if spinel.nil?
|
|
84
|
+
spinel
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def run_with_spinel(spinel, main, dir, entries)
|
|
88
|
+
bin = File.join(dir, "testbin")
|
|
89
|
+
return 1 unless compile(spinel, main, bin, entries)
|
|
90
|
+
|
|
91
|
+
run_command([bin])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def run_with_ruby(main, entries)
|
|
95
|
+
load_path = include_dirs(entries).flat_map { |dir| ["-I", dir] }
|
|
96
|
+
run_command([RbConfig.ruby] + load_path + [main])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def run_command(command)
|
|
100
|
+
args = []
|
|
101
|
+
args += ["-n", @filter] if @filter
|
|
102
|
+
args += ["-t", @testcase] if @testcase
|
|
103
|
+
system(*command, *args) ? 0 : ($?.exitstatus || 1)
|
|
104
|
+
end
|
|
105
|
+
|
|
76
106
|
def parse(args)
|
|
77
107
|
until args.empty?
|
|
78
108
|
arg = args.shift
|
|
@@ -82,16 +112,27 @@ module SpinelTest
|
|
|
82
112
|
@exit = 0
|
|
83
113
|
return
|
|
84
114
|
when "-n", "--name"
|
|
85
|
-
if args.empty?
|
|
86
|
-
|
|
87
|
-
end
|
|
115
|
+
return usage_error("#{arg} requires a value") if args.empty?
|
|
116
|
+
|
|
88
117
|
@filter = args.shift
|
|
118
|
+
return unless valid_pattern?(@filter)
|
|
119
|
+
when "-t", "--testcase"
|
|
120
|
+
return usage_error("#{arg} requires a value") if args.empty?
|
|
121
|
+
|
|
122
|
+
@testcase = args.shift
|
|
123
|
+
return unless valid_pattern?(@testcase)
|
|
124
|
+
when "--ruby" then @ruby = true
|
|
89
125
|
when "-I"
|
|
90
126
|
return usage_error("-I requires a value") if args.empty?
|
|
91
127
|
|
|
92
128
|
@includes << File.expand_path(args.shift, @root)
|
|
93
129
|
when /\A-I(.+)\z/ then @includes << File.expand_path($1, @root)
|
|
94
|
-
when /\A--name=(.*)\z/
|
|
130
|
+
when /\A--name=(.*)\z/
|
|
131
|
+
@filter = $1
|
|
132
|
+
return unless valid_pattern?(@filter)
|
|
133
|
+
when /\A--testcase=(.*)\z/
|
|
134
|
+
@testcase = $1
|
|
135
|
+
return unless valid_pattern?(@testcase)
|
|
95
136
|
when "--" then @paths.concat(args.shift(args.size))
|
|
96
137
|
when /\A-/ then return usage_error("unknown option: #{arg}")
|
|
97
138
|
else @paths << arg
|
|
@@ -99,6 +140,16 @@ module SpinelTest
|
|
|
99
140
|
end
|
|
100
141
|
end
|
|
101
142
|
|
|
143
|
+
def valid_pattern?(value)
|
|
144
|
+
return true unless value.length >= 2 && value.start_with?("/") && value.end_with?("/")
|
|
145
|
+
|
|
146
|
+
Regexp.new(value[1..-2])
|
|
147
|
+
true
|
|
148
|
+
rescue RegexpError => e
|
|
149
|
+
usage_error("invalid regexp #{value}: #{e.message}")
|
|
150
|
+
false
|
|
151
|
+
end
|
|
152
|
+
|
|
102
153
|
def usage_error(message)
|
|
103
154
|
warn "spinel_test-unit: #{message}"
|
|
104
155
|
warn USAGE
|
|
@@ -134,6 +185,7 @@ module SpinelTest
|
|
|
134
185
|
warn "spinel_test-unit: #{display(file)}: #{name} inherits from #{parent}, which is not defined in a scanned file; its tests are ignored"
|
|
135
186
|
end
|
|
136
187
|
|
|
188
|
+
result.classes.each { |c| c.locations.transform_values! { |location| display(location) } }
|
|
137
189
|
by_file = result.classes.group_by(&:file)
|
|
138
190
|
order = []
|
|
139
191
|
files.each do |file|
|
|
@@ -219,7 +271,13 @@ module SpinelTest
|
|
|
219
271
|
|
|
220
272
|
def compile(spinel, main, bin, entries)
|
|
221
273
|
cmd = [spinel] + Toolchain.compile_flags + include_dirs(entries).flat_map { |d| ["-I", d] } + [main, "-o", bin]
|
|
222
|
-
|
|
274
|
+
begin
|
|
275
|
+
output, status = Open3.capture2e(*cmd)
|
|
276
|
+
rescue SystemCallError => e
|
|
277
|
+
hint = ENV["SPINEL"].to_s.empty? ? "" : " (check SPINEL)"
|
|
278
|
+
warn "spinel_test-unit: cannot run Spinel: #{e.message}#{hint}"
|
|
279
|
+
return false
|
|
280
|
+
end
|
|
223
281
|
unless status.success?
|
|
224
282
|
warn output unless output.empty?
|
|
225
283
|
warn "spinel_test-unit: compilation failed (#{cmd.join(" ")})"
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpinelTest
|
|
4
|
+
module Unit
|
|
5
|
+
class Diff
|
|
6
|
+
MAX_TABLE_CELLS = 1_000_000
|
|
7
|
+
|
|
8
|
+
def self.lines(expected, actual, context = 2)
|
|
9
|
+
new(expected.split("\n", -1), actual.split("\n", -1), context).render
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(expected_lines, actual_lines, context)
|
|
13
|
+
@expected_lines = expected_lines
|
|
14
|
+
@actual_lines = actual_lines
|
|
15
|
+
@context = context
|
|
16
|
+
@prefix = common_prefix_length
|
|
17
|
+
@suffix = common_suffix_length
|
|
18
|
+
@expected_middle = middle(expected_lines)
|
|
19
|
+
@actual_middle = middle(actual_lines)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def render
|
|
23
|
+
if @expected_middle.length * @actual_middle.length > MAX_TABLE_CELLS
|
|
24
|
+
return " (diff skipped: #{@expected_middle.length} expected and #{@actual_middle.length} actual lines differ)"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
entries = unchanged(@expected_lines[0, @prefix]) + edits + unchanged(@expected_lines[@expected_lines.length - @suffix, @suffix])
|
|
28
|
+
collapse(entries)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def common_prefix_length
|
|
34
|
+
limit = @expected_lines.length < @actual_lines.length ? @expected_lines.length : @actual_lines.length
|
|
35
|
+
count = 0
|
|
36
|
+
count += 1 while count < limit && @expected_lines[count] == @actual_lines[count]
|
|
37
|
+
count
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def common_suffix_length
|
|
41
|
+
limit = (@expected_lines.length < @actual_lines.length ? @expected_lines.length : @actual_lines.length) - @prefix
|
|
42
|
+
count = 0
|
|
43
|
+
while count < limit && @expected_lines[@expected_lines.length - 1 - count] == @actual_lines[@actual_lines.length - 1 - count]
|
|
44
|
+
count += 1
|
|
45
|
+
end
|
|
46
|
+
count
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def middle(lines)
|
|
50
|
+
lines[@prefix, lines.length - @prefix - @suffix]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def unchanged(lines)
|
|
54
|
+
lines.map { |line| " " + display(line) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def display(line)
|
|
58
|
+
ambiguous?(line) ? line.inspect : line
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def ambiguous?(line)
|
|
62
|
+
line == "" || line == "..." || control?(line) || line.start_with?("\"") ||
|
|
63
|
+
line.start_with?(" ", "\t") || line.end_with?(" ", "\t")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def control?(line)
|
|
67
|
+
line.bytes.any? { |b| (b < 32 && b != 9) || b == 127 }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def edits
|
|
71
|
+
lengths = lcs_lengths
|
|
72
|
+
entries = []
|
|
73
|
+
expected_index = 0
|
|
74
|
+
actual_index = 0
|
|
75
|
+
while expected_index < @expected_middle.length && actual_index < @actual_middle.length
|
|
76
|
+
if @expected_middle[expected_index] == @actual_middle[actual_index]
|
|
77
|
+
entries.push(" " + display(@expected_middle[expected_index]))
|
|
78
|
+
expected_index += 1
|
|
79
|
+
actual_index += 1
|
|
80
|
+
elsif lengths[expected_index + 1][actual_index] >= lengths[expected_index][actual_index + 1]
|
|
81
|
+
entries.push("- " + display(@expected_middle[expected_index]))
|
|
82
|
+
expected_index += 1
|
|
83
|
+
else
|
|
84
|
+
entries.push("+ " + display(@actual_middle[actual_index]))
|
|
85
|
+
actual_index += 1
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
push_rest(entries, "- ", @expected_middle, expected_index)
|
|
89
|
+
push_rest(entries, "+ ", @actual_middle, actual_index)
|
|
90
|
+
entries
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def push_rest(entries, marker, lines, from)
|
|
94
|
+
index = from
|
|
95
|
+
while index < lines.length
|
|
96
|
+
entries.push(marker + display(lines[index]))
|
|
97
|
+
index += 1
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def lcs_lengths
|
|
102
|
+
lengths = Array.new(@expected_middle.length + 1) { Array.new(@actual_middle.length + 1, 0) }
|
|
103
|
+
expected_index = @expected_middle.length - 1
|
|
104
|
+
while expected_index >= 0
|
|
105
|
+
actual_index = @actual_middle.length - 1
|
|
106
|
+
while actual_index >= 0
|
|
107
|
+
if @expected_middle[expected_index] == @actual_middle[actual_index]
|
|
108
|
+
lengths[expected_index][actual_index] = lengths[expected_index + 1][actual_index + 1] + 1
|
|
109
|
+
else
|
|
110
|
+
down = lengths[expected_index + 1][actual_index]
|
|
111
|
+
right = lengths[expected_index][actual_index + 1]
|
|
112
|
+
lengths[expected_index][actual_index] = down >= right ? down : right
|
|
113
|
+
end
|
|
114
|
+
actual_index -= 1
|
|
115
|
+
end
|
|
116
|
+
expected_index -= 1
|
|
117
|
+
end
|
|
118
|
+
lengths
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def collapse(entries)
|
|
122
|
+
visible = visible_entries(entries)
|
|
123
|
+
out = []
|
|
124
|
+
index = 0
|
|
125
|
+
while index < entries.length
|
|
126
|
+
run_end = hidden_run_end(visible, index)
|
|
127
|
+
if run_end - index >= 2
|
|
128
|
+
out.push(" ...")
|
|
129
|
+
index = run_end
|
|
130
|
+
else
|
|
131
|
+
out.push(entries[index])
|
|
132
|
+
index += 1
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
out.join("\n")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def hidden_run_end(visible, from)
|
|
139
|
+
index = from
|
|
140
|
+
index += 1 while index < visible.length && !visible[index]
|
|
141
|
+
index
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def visible_entries(entries)
|
|
145
|
+
visible = Array.new(entries.length, false)
|
|
146
|
+
entries.each_with_index do |entry, index|
|
|
147
|
+
next if entry.start_with?(" ")
|
|
148
|
+
|
|
149
|
+
first = index - @context
|
|
150
|
+
first = 0 if first < 0
|
|
151
|
+
last = index + @context
|
|
152
|
+
last = entries.length - 1 if last >= entries.length
|
|
153
|
+
first.upto(last) { |position| visible[position] = true }
|
|
154
|
+
end
|
|
155
|
+
visible
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -4,16 +4,18 @@ module SpinelTest
|
|
|
4
4
|
module Unit
|
|
5
5
|
module Generator
|
|
6
6
|
# files: [{ require: "test/foo_test", classes: [TestClass, ...] }, ...]
|
|
7
|
-
|
|
7
|
+
# root: the directory the failure locations are shown relative to.
|
|
8
|
+
def self.generate(files, root = "")
|
|
8
9
|
out = +"require \"spinel_test/unit\"\n"
|
|
9
10
|
files.each { |f| out << "require #{f[:require].dump}\n" }
|
|
10
11
|
out << ARGV_PARSER
|
|
11
|
-
out << "runner = SpinelTest::Unit::Runner.new(filter)\n"
|
|
12
|
+
out << "runner = SpinelTest::Unit::Runner.new(filter, #{root.dump}, testcase)\n"
|
|
12
13
|
files.each do |f|
|
|
13
14
|
f[:classes].each do |klass|
|
|
15
|
+
locations = klass.locations || {}
|
|
14
16
|
klass.methods.each do |m|
|
|
15
17
|
out << "t = #{klass.name}.new\n"
|
|
16
|
-
out << "runner.run(#{"#{klass.name}##{m}".dump}, t) { t.#{m} }\n"
|
|
18
|
+
out << "runner.run(#{"#{klass.name}##{m}".dump}, t, #{locations[m].to_s.dump}) { t.#{m} }\n"
|
|
17
19
|
end
|
|
18
20
|
end
|
|
19
21
|
end
|
|
@@ -23,11 +25,15 @@ module SpinelTest
|
|
|
23
25
|
|
|
24
26
|
ARGV_PARSER = <<~RUBY
|
|
25
27
|
filter = ""
|
|
28
|
+
testcase = ""
|
|
26
29
|
i = 0
|
|
27
30
|
while i < ARGV.length
|
|
28
31
|
if ARGV[i] == "-n" && i + 1 < ARGV.length
|
|
29
32
|
filter = ARGV[i + 1]
|
|
30
33
|
i += 1
|
|
34
|
+
elsif ARGV[i] == "-t" && i + 1 < ARGV.length
|
|
35
|
+
testcase = ARGV[i + 1]
|
|
36
|
+
i += 1
|
|
31
37
|
end
|
|
32
38
|
i += 1
|
|
33
39
|
end
|
|
@@ -3,8 +3,13 @@
|
|
|
3
3
|
module SpinelTest
|
|
4
4
|
module Unit
|
|
5
5
|
class Runner
|
|
6
|
-
|
|
6
|
+
MAX_FRAMES = 3
|
|
7
|
+
FRAMEWORK_DIR = File.dirname(File.expand_path(__FILE__)) + "/"
|
|
8
|
+
|
|
9
|
+
def initialize(filter = "", root = "", testcase = "")
|
|
7
10
|
@filter = filter
|
|
11
|
+
@root = root
|
|
12
|
+
@testcase = testcase
|
|
8
13
|
@tests = 0
|
|
9
14
|
@assertions = 0
|
|
10
15
|
@failures = 0
|
|
@@ -13,27 +18,46 @@ module SpinelTest
|
|
|
13
18
|
end
|
|
14
19
|
|
|
15
20
|
def selected?(name)
|
|
16
|
-
|
|
21
|
+
index = name.index("#")
|
|
22
|
+
klass = index ? name[0, index] : name
|
|
23
|
+
matches?(@testcase, klass, true) && matches?(@filter, name, false)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def matches?(filter, text, exact)
|
|
27
|
+
return true if filter == ""
|
|
28
|
+
return Regexp.new(filter[1, filter.length - 2]).match?(text) if pattern?(filter)
|
|
29
|
+
|
|
30
|
+
exact ? text == filter : text.include?(filter)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def pattern?(filter)
|
|
34
|
+
filter.length >= 2 && filter.start_with?("/") && filter.end_with?("/")
|
|
17
35
|
end
|
|
18
36
|
|
|
19
37
|
# Runs one test: setup, the block (which calls the test method), teardown.
|
|
20
|
-
|
|
38
|
+
# location is the "file:line" of the test method, shown when the exception
|
|
39
|
+
# has no backtrace of its own.
|
|
40
|
+
def run(name, tc, location = "")
|
|
21
41
|
return unless selected?(name)
|
|
22
42
|
|
|
23
43
|
@tests += 1
|
|
24
44
|
status = 0
|
|
25
45
|
message = ""
|
|
46
|
+
frames = []
|
|
47
|
+
teardown_message = ""
|
|
26
48
|
begin
|
|
27
49
|
tc.setup
|
|
28
50
|
yield
|
|
29
51
|
rescue AssertionFailedError => e
|
|
30
52
|
status = 1
|
|
31
53
|
message = e.message
|
|
54
|
+
frames = report_frames(e, location)
|
|
32
55
|
rescue Exception => e
|
|
33
56
|
raise e if fatal?(e)
|
|
34
57
|
|
|
35
58
|
status = 2
|
|
36
59
|
message = e.message
|
|
60
|
+
frames = report_frames(e, location)
|
|
37
61
|
end
|
|
38
62
|
begin
|
|
39
63
|
tc.teardown
|
|
@@ -43,8 +67,9 @@ module SpinelTest
|
|
|
43
67
|
if status == 0
|
|
44
68
|
status = 2
|
|
45
69
|
message = e.message
|
|
70
|
+
frames = report_frames(e, location)
|
|
46
71
|
else
|
|
47
|
-
|
|
72
|
+
teardown_message = "\nTeardown also raised: " + e.message
|
|
48
73
|
end
|
|
49
74
|
end
|
|
50
75
|
@assertions += tc.assertions
|
|
@@ -53,19 +78,69 @@ module SpinelTest
|
|
|
53
78
|
elsif status == 1
|
|
54
79
|
print "F"
|
|
55
80
|
@failures += 1
|
|
56
|
-
@details.push("Failure: #{name}\n#{message}")
|
|
81
|
+
@details.push("Failure: #{name}\n#{message}#{where(frames)}#{teardown_message}")
|
|
57
82
|
else
|
|
58
83
|
print "E"
|
|
59
84
|
@errors += 1
|
|
60
|
-
@details.push("Error: #{name}\n#{message}")
|
|
85
|
+
@details.push("Error: #{name}\n#{message}#{where(frames)}#{teardown_message}")
|
|
61
86
|
end
|
|
62
87
|
end
|
|
63
88
|
|
|
89
|
+
def report_frames(e, location)
|
|
90
|
+
backtrace = e.backtrace || []
|
|
91
|
+
frames = []
|
|
92
|
+
backtrace.each do |frame|
|
|
93
|
+
next if framework_frame?(frame)
|
|
94
|
+
next unless frames.empty? || under_root?(frame)
|
|
95
|
+
|
|
96
|
+
shown = relative_path(frame_location(frame))
|
|
97
|
+
next if frames.length > 0 && frames[frames.length - 1] == shown
|
|
98
|
+
|
|
99
|
+
frames.push(shown)
|
|
100
|
+
break if frames.length >= MAX_FRAMES
|
|
101
|
+
end
|
|
102
|
+
frames.push(location) if frames.empty? && location != ""
|
|
103
|
+
frames
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def under_root?(frame)
|
|
107
|
+
@root == "" || frame.start_with?(@root + "/")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def framework_frame?(frame)
|
|
111
|
+
frame.start_with?(FRAMEWORK_DIR) || frame.start_with?($0 + ":")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def frame_location(frame)
|
|
115
|
+
index = frame.index(":in ")
|
|
116
|
+
index ? frame[0, index] : frame
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def relative_path(location)
|
|
120
|
+
return location if @root == ""
|
|
121
|
+
|
|
122
|
+
prefix = @root + "/"
|
|
123
|
+
location.start_with?(prefix) ? location[prefix.length, location.length] : location
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def where(frames)
|
|
127
|
+
text = ""
|
|
128
|
+
frames.each { |frame| text += "\n at " + frame }
|
|
129
|
+
text
|
|
130
|
+
end
|
|
131
|
+
|
|
64
132
|
# Exceptions that must end the process instead of being reported as a test error.
|
|
65
133
|
def fatal?(e)
|
|
66
134
|
e.is_a?(SystemExit) || e.is_a?(Interrupt)
|
|
67
135
|
end
|
|
68
136
|
|
|
137
|
+
def filter_description
|
|
138
|
+
parts = []
|
|
139
|
+
parts.push("testcase: " + @testcase) if @testcase != ""
|
|
140
|
+
parts.push("filter: " + @filter) if @filter != ""
|
|
141
|
+
parts.empty? ? "" : " (" + parts.join(", ") + ")"
|
|
142
|
+
end
|
|
143
|
+
|
|
69
144
|
def passed?
|
|
70
145
|
@failures == 0 && @errors == 0
|
|
71
146
|
end
|
|
@@ -84,7 +159,7 @@ module SpinelTest
|
|
|
84
159
|
puts ""
|
|
85
160
|
puts summary
|
|
86
161
|
if @tests == 0
|
|
87
|
-
puts "No tests were run#{
|
|
162
|
+
puts "No tests were run#{filter_description}"
|
|
88
163
|
return 1
|
|
89
164
|
end
|
|
90
165
|
passed? ? 0 : 1
|
|
@@ -8,10 +8,10 @@ module SpinelTest
|
|
|
8
8
|
VISIBILITY = %i[private protected public].freeze
|
|
9
9
|
BASE_NAMES = ["SpinelTest::Unit::TestCase", "::SpinelTest::Unit::TestCase"].freeze
|
|
10
10
|
|
|
11
|
-
TestClass = Struct.new(:name, :methods, :file, :files, keyword_init: true)
|
|
11
|
+
TestClass = Struct.new(:name, :methods, :file, :files, :locations, keyword_init: true)
|
|
12
12
|
# superclass is the raw name as written; scope is the lexical namespace it
|
|
13
13
|
# was written in. Both are qualified in resolve, once every class is known.
|
|
14
|
-
ClassInfo = Struct.new(:name, :superclass, :methods, :files, :hidden, :scope)
|
|
14
|
+
ClassInfo = Struct.new(:name, :superclass, :methods, :files, :hidden, :scope, :locations)
|
|
15
15
|
Result = Struct.new(:classes, :warnings, keyword_init: true)
|
|
16
16
|
|
|
17
17
|
def self.scan(sources)
|
|
@@ -68,7 +68,7 @@ module SpinelTest
|
|
|
68
68
|
visit(node.body, namespace + [path_name(node.constant_path)]) if node.body
|
|
69
69
|
when Prism::ClassNode
|
|
70
70
|
name = (namespace + [path_name(node.constant_path)]).join("::")
|
|
71
|
-
info = (@classes[name] ||= ClassInfo.new(name, nil, [], []))
|
|
71
|
+
info = (@classes[name] ||= ClassInfo.new(name, nil, [], [], nil, nil, {}))
|
|
72
72
|
info.files << @file if @file && !info.files.include?(@file)
|
|
73
73
|
if node.superclass
|
|
74
74
|
info.superclass = path_name(node.superclass)
|
|
@@ -107,7 +107,10 @@ module SpinelTest
|
|
|
107
107
|
name = stmt.name.to_s
|
|
108
108
|
next if info.hidden&.include?(name)
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
next unless name.start_with?("test_")
|
|
111
|
+
|
|
112
|
+
info.methods << name unless info.methods.include?(name)
|
|
113
|
+
info.locations[name] = "#{@file}:#{stmt.location.start_line}" if @file
|
|
111
114
|
end
|
|
112
115
|
end
|
|
113
116
|
|
|
@@ -148,7 +151,8 @@ module SpinelTest
|
|
|
148
151
|
|
|
149
152
|
files = chain.reverse.flat_map(&:files).uniq
|
|
150
153
|
file = (targets && info.files.find { |f| targets.include?(f) }) || info.files.first
|
|
151
|
-
|
|
154
|
+
locations = chain.reverse.reduce({}) { |all, link| all.merge(link.locations) }
|
|
155
|
+
TestClass.new(name: info.name, methods: methods, file: file, files: files, locations: locations)
|
|
152
156
|
end
|
|
153
157
|
end
|
|
154
158
|
|
|
@@ -27,34 +27,67 @@ module SpinelTest
|
|
|
27
27
|
raise AssertionFailedError, (message || "Failed assertion, no message given.")
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
def refute(test, message = nil)
|
|
31
|
+
@assertions += 1
|
|
32
|
+
return true unless test
|
|
33
|
+
|
|
34
|
+
raise AssertionFailedError, (message || "Failed refutation, no message given.")
|
|
35
|
+
end
|
|
36
|
+
|
|
30
37
|
def assert_equal(expected, actual, message = nil)
|
|
31
38
|
@assertions += 1
|
|
32
39
|
return true if expected == actual
|
|
33
40
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
failure("Expected: #{expected.inspect}\n Actual: #{actual.inspect}" + diff_section(expected, actual), message)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def assert_not_equal(expected, actual, message = nil)
|
|
45
|
+
@assertions += 1
|
|
46
|
+
return true unless expected == actual
|
|
47
|
+
|
|
48
|
+
failure("Expected #{actual.inspect} not to equal #{expected.inspect}", message)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def assert_match(pattern, string, message = nil)
|
|
52
|
+
@assertions += 1
|
|
53
|
+
return true if pattern_matches?(pattern, string)
|
|
54
|
+
|
|
55
|
+
failure("Expected #{pattern.inspect} to match #{string.inspect}", message)
|
|
37
56
|
end
|
|
38
57
|
|
|
39
|
-
def assert_nil(
|
|
58
|
+
def assert_nil(value, message = nil)
|
|
40
59
|
@assertions += 1
|
|
41
|
-
return true if
|
|
60
|
+
return true if value.nil?
|
|
42
61
|
|
|
43
|
-
|
|
44
|
-
msg = message + "\n" + msg if message
|
|
45
|
-
raise AssertionFailedError, msg
|
|
62
|
+
failure("Expected nil, got #{value.inspect}", message)
|
|
46
63
|
end
|
|
47
64
|
|
|
48
|
-
def
|
|
65
|
+
def assert_not_nil(value, message = nil)
|
|
49
66
|
@assertions += 1
|
|
50
|
-
return true
|
|
67
|
+
return true unless value.nil?
|
|
51
68
|
|
|
52
|
-
|
|
53
|
-
msg = message + "\n" + msg if message
|
|
54
|
-
raise AssertionFailedError, msg
|
|
69
|
+
failure("Expected a value other than nil", message)
|
|
55
70
|
end
|
|
56
71
|
|
|
57
|
-
def
|
|
72
|
+
def assert_include(collection, item, message = nil)
|
|
73
|
+
@assertions += 1
|
|
74
|
+
return true if collection.include?(item)
|
|
75
|
+
|
|
76
|
+
failure("#{collection.inspect} does not include #{item.inspect}", message)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def assert_includes(collection, item, message = nil)
|
|
80
|
+
assert_include(collection, item, message)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def assert_not_include(collection, item, message = nil)
|
|
84
|
+
@assertions += 1
|
|
85
|
+
return true unless collection.include?(item)
|
|
86
|
+
|
|
87
|
+
failure("#{collection.inspect} includes #{item.inspect}", message)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def assert_raise(klass, match: nil)
|
|
58
91
|
@assertions += 1
|
|
59
92
|
begin
|
|
60
93
|
yield
|
|
@@ -62,12 +95,54 @@ module SpinelTest
|
|
|
62
95
|
# a failed assertion inside the block is not "the expected error"
|
|
63
96
|
raise e
|
|
64
97
|
rescue StandardError => e
|
|
65
|
-
return e
|
|
98
|
+
return expected_exception(e, klass, match, e.instance_of?(klass))
|
|
99
|
+
end
|
|
100
|
+
raise AssertionFailedError, "Expected #{klass} but nothing was raised"
|
|
101
|
+
end
|
|
66
102
|
|
|
67
|
-
|
|
103
|
+
def assert_raise_kind_of(klass, match: nil)
|
|
104
|
+
@assertions += 1
|
|
105
|
+
begin
|
|
106
|
+
yield
|
|
107
|
+
rescue AssertionFailedError => e
|
|
108
|
+
raise e
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
return expected_exception(e, klass, match, e.is_a?(klass))
|
|
68
111
|
end
|
|
69
112
|
raise AssertionFailedError, "Expected #{klass} but nothing was raised"
|
|
70
113
|
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def failure(text, message)
|
|
118
|
+
raise AssertionFailedError, message ? message + "\n" + text : text
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def expected_exception(e, klass, match, class_matches)
|
|
122
|
+
raise AssertionFailedError, "Expected #{klass} but got #{e.class}" unless class_matches
|
|
123
|
+
return e if match.nil? || pattern_matches?(match, e.message)
|
|
124
|
+
|
|
125
|
+
raise AssertionFailedError, "Expected #{klass} with a message matching #{match.inspect} but got #{e.message.inspect}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def diff_section(expected, actual)
|
|
129
|
+
return "" unless multiline_strings?(expected, actual)
|
|
130
|
+
|
|
131
|
+
"\nDiff:\n" + Diff.lines(expected, actual)
|
|
132
|
+
rescue ArgumentError, EncodingError
|
|
133
|
+
""
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def multiline_strings?(expected, actual)
|
|
137
|
+
expected.is_a?(String) && actual.is_a?(String) && expected.include?("\n") && actual.include?("\n")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def pattern_matches?(pattern, string)
|
|
141
|
+
return false unless string.is_a?(String)
|
|
142
|
+
return pattern.match?(string) if pattern.is_a?(Regexp)
|
|
143
|
+
|
|
144
|
+
string.include?(pattern)
|
|
145
|
+
end
|
|
71
146
|
end
|
|
72
147
|
end
|
|
73
148
|
end
|
data/lib/spinel_test/unit.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: spinel_test-unit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yuji Yaginuma
|
|
@@ -58,6 +58,7 @@ files:
|
|
|
58
58
|
- exe/spinel_test-unit
|
|
59
59
|
- lib/spinel_test/unit.rb
|
|
60
60
|
- lib/spinel_test/unit/cli.rb
|
|
61
|
+
- lib/spinel_test/unit/diff.rb
|
|
61
62
|
- lib/spinel_test/unit/generator.rb
|
|
62
63
|
- lib/spinel_test/unit/runner.rb
|
|
63
64
|
- lib/spinel_test/unit/scanner.rb
|
|
@@ -70,6 +71,7 @@ licenses:
|
|
|
70
71
|
metadata:
|
|
71
72
|
homepage_uri: https://github.com/y-yagi/spinel_test-unit
|
|
72
73
|
source_code_uri: https://github.com/y-yagi/spinel_test-unit
|
|
74
|
+
rubygems_mfa_required: 'true'
|
|
73
75
|
rdoc_options: []
|
|
74
76
|
require_paths:
|
|
75
77
|
- lib
|