spinel_test-unit 0.1.2 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a36d406fd907591eb344d09238539b48c5c284a629847fca832dd696114b4cd4
4
- data.tar.gz: d982c5bafe885e787e93f60b4434b75f295f54df415bb73aa35e25123a5a6150
3
+ metadata.gz: f445b0e6c7e31a0821e0ac04121407864475e56e85ae48326e548ded202b94d2
4
+ data.tar.gz: f541193fc3cda7755d9a4d233367846af00822a84daf209d8015c1f5b5326304
5
5
  SHA512:
6
- metadata.gz: 6f17eda9a7f16b435bb057fc6718ae3930ba5dd2c492fbafa60c840e7918d09db300851b57917d8d027a1d53b8b06f807b53ecd9bf595a82981494e3b1521739
7
- data.tar.gz: c9b3ff1bea0017c169a937121008e2d7527da44b852bde7ca284eeb14d85114bf84d42a6f5262cfc5e62138f8db7f52607d9b7177b0e79378be17451e807af01
6
+ metadata.gz: 1d3e3ec35074600ed21fb5765bc60b72e77235567850c5ecff1811f9ceb9d7b3fd8db73be99817f39dde0864e5a20b6962c5a6f943c4fabe4f4475a5525290a8
7
+ data.tar.gz: 892971495ea188d9e80c4f962cb6121cfab754d4db98c7a2c8f8e8809d161166522586d0b15a0bee6837068dbc0daa0ece5ff2ecb79ccdaa0d9412bd14f64d0e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  ## Unreleased
2
2
 
3
+ ## 0.2.0
4
+
5
+ * Report where a test failed: `at file:line` lines after the message (the test method's line with Spinel, the filtered backtrace with `--ruby`)
6
+ * Show a line diff when `assert_equal` fails on two multi-line Strings
7
+ * 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`
8
+ * Add `--ruby` to run the tests with the current Ruby instead of compiling them with Spinel
9
+
3
10
  ## 0.1.2
4
11
 
5
12
  * Exclude mise.toml from the gem
data/README.md CHANGED
@@ -39,25 +39,60 @@ 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 --ruby # run the same tests with the current Ruby, no Spinel needed
42
43
  ```
43
44
 
44
- Output:
45
+ `--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.
46
+
47
+ Output when compiled with Spinel:
45
48
 
46
49
  ```
47
- ..F
50
+ .F
48
51
 
49
52
  Failure: CalcTest#test_failure_message
50
53
  1 + 1 should be 2
54
+ at test/calc_test.rb:14
55
+
56
+ 2 tests, 2 assertions, 1 failures, 0 errors
57
+ ```
58
+
59
+ 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):
60
+
61
+ - Compiled with Spinel, exceptions have no backtrace, so there is one line: the `file:line` of the test method's `def`.
62
+ - 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.
63
+ - 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`).
64
+ - If a test method is defined more than once, the location of the last definition is used.
65
+
66
+ 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
67
 
52
- 3 tests, 3 assertions, 1 failures, 0 errors
53
68
  ```
69
+ Failure: HttpTest#test_to_cgi
70
+ Expected: "a\nb\nc\nd\ne\nf"
71
+ Actual: "a\nb\nX\nd\ne\nf"
72
+ Diff:
73
+ a
74
+ b
75
+ - c
76
+ + X
77
+ d
78
+ e
79
+ f
80
+ at test/http_test.rb:7
81
+ ```
82
+
83
+ 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
84
 
55
85
  Available assertions:
56
86
 
57
87
  - `assert(test, message = nil)`
58
88
  - `assert_equal(expected, actual, message = nil)`
89
+ - `assert_not_equal(expected, actual, message = nil)`
59
90
  - `assert_nil(value, message = nil)`
60
- - `assert_includes(collection, item, message = nil)`
91
+ - `assert_not_nil(value, message = nil)`
92
+ - `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)
93
+ - `assert_include(collection, item, message = nil)` (`assert_includes` is an alias)
94
+ - `assert_not_include(collection, item, message = nil)`
95
+ - `refute(test, message = nil)`
61
96
  - `assert_raise(klass) { ... }` (passes for `klass` or a subclass; returns the exception; assertion failures raised inside the block propagate)
62
97
 
63
98
  `setup` and `teardown` hooks are supported.
@@ -83,6 +118,7 @@ The tests are compiled for build speed, not run speed: with `-O0`, and the gener
83
118
  - Files are loaded with `require` and the load paths `lib`, `test` and the
84
119
  project root; use `require "..."` / `require_relative` that Spinel resolves.
85
120
  - Test files under `fixtures/` directories (relative to the project root) are skipped unless named explicitly or the given directory is itself inside `fixtures/`.
121
+ - 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
122
  - `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
123
 
88
124
  ## Development
@@ -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] [-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
@@ -23,19 +26,22 @@ module SpinelTest
23
26
  @paths = []
24
27
  @filter = nil
25
28
  @keep = false
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] [-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
42
  -n, --name NAME run only the tests whose "Class#test_name" includes NAME
38
- -I DIR add DIR to Spinel's load path (repeatable)
43
+ -I DIR add DIR to the load path (repeatable)
44
+ --ruby run with the current Ruby instead of compiling with Spinel
39
45
  -h, --help show this help
40
46
  TEXT
41
47
 
@@ -48,10 +54,9 @@ module SpinelTest
48
54
  return 1
49
55
  end
50
56
 
51
- spinel = Toolchain.spinel
52
- unless spinel
53
- warn "spinel_test-unit: Spinel not found (set SPINEL, put spinel on PATH, or install it with mise)"
54
- return 1
57
+ unless @ruby
58
+ spinel = find_spinel
59
+ return 1 unless spinel
55
60
  end
56
61
 
57
62
  entries = build_entries(files)
@@ -62,17 +67,36 @@ module SpinelTest
62
67
 
63
68
  Dir.mktmpdir("spinel_test-unit") do |dir|
64
69
  main = File.join(dir, "main.rb")
65
- bin = File.join(dir, "testbin")
66
- File.write(main, Generator.generate(entries))
67
- return 1 unless compile(spinel, main, bin, entries)
68
-
69
- args = @filter ? ["-n", @filter] : []
70
- system(bin, *args) ? 0 : ($?.exitstatus || 1)
70
+ File.write(main, Generator.generate(entries, @root))
71
+ @ruby ? run_with_ruby(main, entries) : run_with_spinel(spinel, main, dir, entries)
71
72
  end
72
73
  end
73
74
 
74
75
  private
75
76
 
77
+ def find_spinel
78
+ spinel = Toolchain.spinel
79
+ warn "spinel_test-unit: Spinel not found (set SPINEL, put spinel on PATH, or install it with mise)" if spinel.nil?
80
+ spinel
81
+ end
82
+
83
+ def run_with_spinel(spinel, main, dir, entries)
84
+ bin = File.join(dir, "testbin")
85
+ return 1 unless compile(spinel, main, bin, entries)
86
+
87
+ run_command([bin])
88
+ end
89
+
90
+ def run_with_ruby(main, entries)
91
+ load_path = include_dirs(entries).flat_map { |dir| ["-I", dir] }
92
+ run_command([RbConfig.ruby] + load_path + [main])
93
+ end
94
+
95
+ def run_command(command)
96
+ args = @filter ? ["-n", @filter] : []
97
+ system(*command, *args) ? 0 : ($?.exitstatus || 1)
98
+ end
99
+
76
100
  def parse(args)
77
101
  until args.empty?
78
102
  arg = args.shift
@@ -86,6 +110,7 @@ module SpinelTest
86
110
  return usage_error("#{arg} requires a value")
87
111
  end
88
112
  @filter = args.shift
113
+ when "--ruby" then @ruby = true
89
114
  when "-I"
90
115
  return usage_error("-I requires a value") if args.empty?
91
116
 
@@ -134,6 +159,7 @@ module SpinelTest
134
159
  warn "spinel_test-unit: #{display(file)}: #{name} inherits from #{parent}, which is not defined in a scanned file; its tests are ignored"
135
160
  end
136
161
 
162
+ result.classes.each { |c| c.locations.transform_values! { |location| display(location) } }
137
163
  by_file = result.classes.group_by(&:file)
138
164
  order = []
139
165
  files.each do |file|
@@ -0,0 +1,155 @@
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 == "..." || line.include?("\r") || line.start_with?("\"") ||
63
+ line.start_with?(" ", "\t") || line.end_with?(" ", "\t")
64
+ end
65
+
66
+ def edits
67
+ lengths = lcs_lengths
68
+ entries = []
69
+ expected_index = 0
70
+ actual_index = 0
71
+ while expected_index < @expected_middle.length && actual_index < @actual_middle.length
72
+ if @expected_middle[expected_index] == @actual_middle[actual_index]
73
+ entries.push(" " + display(@expected_middle[expected_index]))
74
+ expected_index += 1
75
+ actual_index += 1
76
+ elsif lengths[expected_index + 1][actual_index] >= lengths[expected_index][actual_index + 1]
77
+ entries.push("- " + display(@expected_middle[expected_index]))
78
+ expected_index += 1
79
+ else
80
+ entries.push("+ " + display(@actual_middle[actual_index]))
81
+ actual_index += 1
82
+ end
83
+ end
84
+ push_rest(entries, "- ", @expected_middle, expected_index)
85
+ push_rest(entries, "+ ", @actual_middle, actual_index)
86
+ entries
87
+ end
88
+
89
+ def push_rest(entries, marker, lines, from)
90
+ index = from
91
+ while index < lines.length
92
+ entries.push(marker + display(lines[index]))
93
+ index += 1
94
+ end
95
+ end
96
+
97
+ def lcs_lengths
98
+ lengths = Array.new(@expected_middle.length + 1) { Array.new(@actual_middle.length + 1, 0) }
99
+ expected_index = @expected_middle.length - 1
100
+ while expected_index >= 0
101
+ actual_index = @actual_middle.length - 1
102
+ while actual_index >= 0
103
+ if @expected_middle[expected_index] == @actual_middle[actual_index]
104
+ lengths[expected_index][actual_index] = lengths[expected_index + 1][actual_index + 1] + 1
105
+ else
106
+ down = lengths[expected_index + 1][actual_index]
107
+ right = lengths[expected_index][actual_index + 1]
108
+ lengths[expected_index][actual_index] = down >= right ? down : right
109
+ end
110
+ actual_index -= 1
111
+ end
112
+ expected_index -= 1
113
+ end
114
+ lengths
115
+ end
116
+
117
+ def collapse(entries)
118
+ visible = visible_entries(entries)
119
+ out = []
120
+ index = 0
121
+ while index < entries.length
122
+ run_end = hidden_run_end(visible, index)
123
+ if run_end - index >= 2
124
+ out.push(" ...")
125
+ index = run_end
126
+ else
127
+ out.push(entries[index])
128
+ index += 1
129
+ end
130
+ end
131
+ out.join("\n")
132
+ end
133
+
134
+ def hidden_run_end(visible, from)
135
+ index = from
136
+ index += 1 while index < visible.length && !visible[index]
137
+ index
138
+ end
139
+
140
+ def visible_entries(entries)
141
+ visible = Array.new(entries.length, false)
142
+ entries.each_with_index do |entry, index|
143
+ next if entry.start_with?(" ")
144
+
145
+ first = index - @context
146
+ first = 0 if first < 0
147
+ last = index + @context
148
+ last = entries.length - 1 if last >= entries.length
149
+ first.upto(last) { |position| visible[position] = true }
150
+ end
151
+ visible
152
+ end
153
+ end
154
+ end
155
+ 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
- def self.generate(files)
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})\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
@@ -3,8 +3,12 @@
3
3
  module SpinelTest
4
4
  module Unit
5
5
  class Runner
6
- def initialize(filter = "")
6
+ MAX_FRAMES = 3
7
+ FRAMEWORK_DIR = File.dirname(File.expand_path(__FILE__)) + "/"
8
+
9
+ def initialize(filter = "", root = "")
7
10
  @filter = filter
11
+ @root = root
8
12
  @tests = 0
9
13
  @assertions = 0
10
14
  @failures = 0
@@ -17,23 +21,29 @@ module SpinelTest
17
21
  end
18
22
 
19
23
  # Runs one test: setup, the block (which calls the test method), teardown.
20
- def run(name, tc)
24
+ # location is the "file:line" of the test method, shown when the exception
25
+ # has no backtrace of its own.
26
+ def run(name, tc, location = "")
21
27
  return unless selected?(name)
22
28
 
23
29
  @tests += 1
24
30
  status = 0
25
31
  message = ""
32
+ frames = []
33
+ teardown_message = ""
26
34
  begin
27
35
  tc.setup
28
36
  yield
29
37
  rescue AssertionFailedError => e
30
38
  status = 1
31
39
  message = e.message
40
+ frames = report_frames(e, location)
32
41
  rescue Exception => e
33
42
  raise e if fatal?(e)
34
43
 
35
44
  status = 2
36
45
  message = e.message
46
+ frames = report_frames(e, location)
37
47
  end
38
48
  begin
39
49
  tc.teardown
@@ -43,8 +53,9 @@ module SpinelTest
43
53
  if status == 0
44
54
  status = 2
45
55
  message = e.message
56
+ frames = report_frames(e, location)
46
57
  else
47
- message = message + "\nTeardown also raised: " + e.message
58
+ teardown_message = "\nTeardown also raised: " + e.message
48
59
  end
49
60
  end
50
61
  @assertions += tc.assertions
@@ -53,14 +64,57 @@ module SpinelTest
53
64
  elsif status == 1
54
65
  print "F"
55
66
  @failures += 1
56
- @details.push("Failure: #{name}\n#{message}")
67
+ @details.push("Failure: #{name}\n#{message}#{where(frames)}#{teardown_message}")
57
68
  else
58
69
  print "E"
59
70
  @errors += 1
60
- @details.push("Error: #{name}\n#{message}")
71
+ @details.push("Error: #{name}\n#{message}#{where(frames)}#{teardown_message}")
61
72
  end
62
73
  end
63
74
 
75
+ def report_frames(e, location)
76
+ backtrace = e.backtrace || []
77
+ frames = []
78
+ backtrace.each do |frame|
79
+ next if framework_frame?(frame)
80
+ next unless frames.empty? || under_root?(frame)
81
+
82
+ shown = relative_path(frame_location(frame))
83
+ next if frames.length > 0 && frames[frames.length - 1] == shown
84
+
85
+ frames.push(shown)
86
+ break if frames.length >= MAX_FRAMES
87
+ end
88
+ frames.push(location) if frames.empty? && location != ""
89
+ frames
90
+ end
91
+
92
+ def under_root?(frame)
93
+ @root == "" || frame.start_with?(@root + "/")
94
+ end
95
+
96
+ def framework_frame?(frame)
97
+ frame.start_with?(FRAMEWORK_DIR) || frame.start_with?($0 + ":")
98
+ end
99
+
100
+ def frame_location(frame)
101
+ index = frame.index(":in ")
102
+ index ? frame[0, index] : frame
103
+ end
104
+
105
+ def relative_path(location)
106
+ return location if @root == ""
107
+
108
+ prefix = @root + "/"
109
+ location.start_with?(prefix) ? location[prefix.length, location.length] : location
110
+ end
111
+
112
+ def where(frames)
113
+ text = ""
114
+ frames.each { |frame| text += "\n at " + frame }
115
+ text
116
+ end
117
+
64
118
  # Exceptions that must end the process instead of being reported as a test error.
65
119
  def fatal?(e)
66
120
  e.is_a?(SystemExit) || e.is_a?(Interrupt)
@@ -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
- info.methods << name if name.start_with?("test_") && !info.methods.include?(name)
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
- TestClass.new(name: info.name, methods: methods, file: file, files: files)
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,29 +27,77 @@ 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
41
  msg = "Expected: #{expected.inspect}\n Actual: #{actual.inspect}"
42
+ msg += diff_section(expected, actual)
43
+ msg = message + "\n" + msg if message
44
+ raise AssertionFailedError, msg
45
+ end
46
+
47
+ def assert_not_equal(expected, actual, message = nil)
48
+ @assertions += 1
49
+ return true unless expected == actual
50
+
51
+ msg = "Expected #{actual.inspect} not to equal #{expected.inspect}"
35
52
  msg = message + "\n" + msg if message
36
53
  raise AssertionFailedError, msg
37
54
  end
38
55
 
39
- def assert_nil(v, message = nil)
56
+ def assert_match(pattern, string, message = nil)
40
57
  @assertions += 1
41
- return true if v.nil?
58
+ return true if pattern_matches?(pattern, string)
42
59
 
43
- msg = "Expected nil, got #{v.inspect}"
60
+ msg = "Expected #{pattern.inspect} to match #{string.inspect}"
44
61
  msg = message + "\n" + msg if message
45
62
  raise AssertionFailedError, msg
46
63
  end
47
64
 
48
- def assert_includes(coll, item, message = nil)
65
+ def assert_nil(value, message = nil)
49
66
  @assertions += 1
50
- return true if coll.include?(item)
67
+ return true if value.nil?
51
68
 
52
- msg = "#{coll.inspect} does not include #{item.inspect}"
69
+ msg = "Expected nil, got #{value.inspect}"
70
+ msg = message + "\n" + msg if message
71
+ raise AssertionFailedError, msg
72
+ end
73
+
74
+ def assert_not_nil(value, message = nil)
75
+ @assertions += 1
76
+ return true unless value.nil?
77
+
78
+ msg = "Expected a value other than nil"
79
+ msg = message + "\n" + msg if message
80
+ raise AssertionFailedError, msg
81
+ end
82
+
83
+ def assert_include(collection, item, message = nil)
84
+ @assertions += 1
85
+ return true if collection.include?(item)
86
+
87
+ msg = "#{collection.inspect} does not include #{item.inspect}"
88
+ msg = message + "\n" + msg if message
89
+ raise AssertionFailedError, msg
90
+ end
91
+
92
+ def assert_includes(collection, item, message = nil)
93
+ assert_include(collection, item, message)
94
+ end
95
+
96
+ def assert_not_include(collection, item, message = nil)
97
+ @assertions += 1
98
+ return true unless collection.include?(item)
99
+
100
+ msg = "#{collection.inspect} includes #{item.inspect}"
53
101
  msg = message + "\n" + msg if message
54
102
  raise AssertionFailedError, msg
55
103
  end
@@ -68,6 +116,27 @@ module SpinelTest
68
116
  end
69
117
  raise AssertionFailedError, "Expected #{klass} but nothing was raised"
70
118
  end
119
+
120
+ private
121
+
122
+ def diff_section(expected, actual)
123
+ return "" unless multiline_strings?(expected, actual)
124
+
125
+ "\nDiff:\n" + Diff.lines(expected, actual)
126
+ rescue ArgumentError, EncodingError
127
+ ""
128
+ end
129
+
130
+ def multiline_strings?(expected, actual)
131
+ expected.is_a?(String) && actual.is_a?(String) && expected.include?("\n") && actual.include?("\n")
132
+ end
133
+
134
+ def pattern_matches?(pattern, string)
135
+ return false unless string.is_a?(String)
136
+ return pattern.match?(string) if pattern.is_a?(Regexp)
137
+
138
+ string.include?(pattern)
139
+ end
71
140
  end
72
141
  end
73
142
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module SpinelTest
4
4
  module Unit
5
- VERSION = "0.1.2"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "unit/version"
4
+ require_relative "unit/diff"
4
5
  require_relative "unit/test_case"
5
6
  require_relative "unit/runner"
6
7
 
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.1.2
4
+ version: 0.2.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