spinel_test-unit 0.1.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 +7 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +90 -0
- data/Rakefile +11 -0
- data/exe/spinel_test-unit +7 -0
- data/lib/spinel_test/unit/cli.rb +231 -0
- data/lib/spinel_test/unit/generator.rb +37 -0
- data/lib/spinel_test/unit/runner.rb +94 -0
- data/lib/spinel_test/unit/scanner.rb +197 -0
- data/lib/spinel_test/unit/test_case.rb +73 -0
- data/lib/spinel_test/unit/toolchain.rb +31 -0
- data/lib/spinel_test/unit/version.rb +7 -0
- data/lib/spinel_test/unit.rb +7 -0
- data/mise.toml +14 -0
- metadata +90 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c2341cde71f3bee22ab32ced8d1a7db330bfbbc77dccec006bb9acfc7965c2cb
|
|
4
|
+
data.tar.gz: dd0e9c94e0839a62409c8406999bcf11fa60f6b3aec7e2d04ada67e537cc59a3
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: bec8fd80b6355b89a98d4972390468639e7e2cb93a6e7643dd323cb8ffe0b9c006d537d428f62a2b60ecfafb2fd892e56b5047fa7c6c6778fb2fa900285effb3
|
|
7
|
+
data.tar.gz: 561053edd31dc9783b69afadefaea5299b0d74d8765f2597a8dde7bd42f404f5afb83c640e401d9ce49e048df2dfee7a0b4d16093fe881221e7748f521cb80c3
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"spinel_test-unit" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
|
|
4
|
+
|
|
5
|
+
* Participants will be tolerant of opposing views.
|
|
6
|
+
* Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
|
|
7
|
+
* When interpreting the words and actions of others, participants should always assume good intentions.
|
|
8
|
+
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
9
|
+
|
|
10
|
+
If you have any concerns about behaviour within this project, please contact us at ["yuuji.yaginuma@gmail.com"](mailto:"yuuji.yaginuma@gmail.com").
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yuji Yaginuma
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# SpinelTest::Unit
|
|
2
|
+
|
|
3
|
+
An xUnit-style test framework for [Spinel](https://github.com/matz/spinel).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add it to your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "spinel_test-unit"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Spinel itself is found through, in order: the `SPINEL` environment variable,
|
|
14
|
+
`spinel` on `PATH`, `mise which spinel`.
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
# test/calc_test.rb
|
|
20
|
+
require "spinel_test/unit"
|
|
21
|
+
require "calc"
|
|
22
|
+
|
|
23
|
+
class CalcTest < SpinelTest::Unit::TestCase
|
|
24
|
+
def setup
|
|
25
|
+
@calc = Calc.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def test_add
|
|
29
|
+
assert @calc.add(1, 2) == 3
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def test_failure_message
|
|
33
|
+
assert @calc.add(1, 1) == 2, "1 + 1 should be 2"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```console
|
|
39
|
+
$ bundle exec spinel_test-unit # test/**/*_test.rb and test/**/test_*.rb
|
|
40
|
+
$ bundle exec spinel_test-unit test/calc_test.rb
|
|
41
|
+
$ bundle exec spinel_test-unit -n test_add # only tests whose "Class#method" name includes it
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Output:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
..F
|
|
48
|
+
|
|
49
|
+
Failure: CalcTest#test_failure_message
|
|
50
|
+
1 + 1 should be 2
|
|
51
|
+
|
|
52
|
+
3 tests, 3 assertions, 1 failures, 0 errors
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Available assertions:
|
|
56
|
+
|
|
57
|
+
- `assert(test, message = nil)`
|
|
58
|
+
- `assert_equal(expected, actual, message = nil)`
|
|
59
|
+
- `assert_nil(value, message = nil)`
|
|
60
|
+
- `assert_includes(collection, item, message = nil)`
|
|
61
|
+
- `assert_raise(klass) { ... }` (passes for `klass` or a subclass; returns the exception; assertion failures raised inside the block propagate)
|
|
62
|
+
|
|
63
|
+
`setup` and `teardown` hooks are supported.
|
|
64
|
+
|
|
65
|
+
## Constraints (from Spinel)
|
|
66
|
+
|
|
67
|
+
- Test code must be compilable by Spinel: no `send` with dynamic names,
|
|
68
|
+
`define_method`, `instance_methods`, etc. in tests or in the code under test.
|
|
69
|
+
- Test classes must be defined as literal `class FooTest < SpinelTest::Unit::TestCase`
|
|
70
|
+
(or a literal subclass of another test class) with literal `def test_*`.
|
|
71
|
+
Tests are never generated by metaprogramming.
|
|
72
|
+
- A base test class may live in a library on an `-I` path: `require` /
|
|
73
|
+
`require_relative` with string literals are followed transitively from the
|
|
74
|
+
test files, and those files are scanned (not run) to resolve superclasses.
|
|
75
|
+
Only classes in the target test files are run.
|
|
76
|
+
- Files are loaded with `require` and the load paths `lib`, `test` and the
|
|
77
|
+
project root; use `require "..."` / `require_relative` that Spinel resolves.
|
|
78
|
+
- Test files under `fixtures/` directories (relative to the project root) are skipped unless named explicitly or the given directory is itself inside `fixtures/`.
|
|
79
|
+
- `ScriptError`s (such as `NotImplementedError`) raised by a test are reported as errors; a teardown error after a failure is appended to its message.
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
Run `bin/setup` (installs Spinel with [mise](https://mise.jdx.dev/) and the
|
|
84
|
+
gems), then `bundle exec rake test`. The tests use test-unit; the end-to-end
|
|
85
|
+
tests are skipped when Spinel is not available. To use a Spinel checkout, set
|
|
86
|
+
`SPINEL` in a git-ignored `mise.local.toml`.
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
|
|
6
|
+
Rake::TestTask.new(:test) do |t|
|
|
7
|
+
t.libs << "test" << "lib"
|
|
8
|
+
t.test_files = FileList["test/**/*_test.rb", "test/**/test_*.rb"].exclude("test/fixtures/**/*")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
task default: :test
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
require_relative "scanner"
|
|
7
|
+
require_relative "generator"
|
|
8
|
+
require_relative "toolchain"
|
|
9
|
+
|
|
10
|
+
module SpinelTest
|
|
11
|
+
module Unit
|
|
12
|
+
# spinel_test-unit [PATH...] [-n NAME]
|
|
13
|
+
#
|
|
14
|
+
# Scans the test files with Prism, generates a main.rb that calls every
|
|
15
|
+
# test, compiles it with Spinel and runs the binary.
|
|
16
|
+
class CLI
|
|
17
|
+
def self.start(argv, root: Dir.pwd)
|
|
18
|
+
new(argv, root: root).run
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(argv, root: Dir.pwd)
|
|
22
|
+
@root = File.expand_path(root)
|
|
23
|
+
@paths = []
|
|
24
|
+
@filter = nil
|
|
25
|
+
@keep = false
|
|
26
|
+
@includes = []
|
|
27
|
+
@exit = nil
|
|
28
|
+
parse(argv.dup)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
USAGE = <<~TEXT
|
|
32
|
+
Usage: spinel_test-unit [PATH...] [-n NAME] [-I DIR]
|
|
33
|
+
|
|
34
|
+
Compiles the tests with Spinel and runs them.
|
|
35
|
+
|
|
36
|
+
PATH test file or directory (default: test)
|
|
37
|
+
-n, --name NAME run only the tests whose "Class#test_name" includes NAME
|
|
38
|
+
-I DIR add DIR to Spinel's load path (repeatable)
|
|
39
|
+
-h, --help show this help
|
|
40
|
+
TEXT
|
|
41
|
+
|
|
42
|
+
def run
|
|
43
|
+
return @exit if @exit
|
|
44
|
+
|
|
45
|
+
files = test_files
|
|
46
|
+
if files.empty?
|
|
47
|
+
warn "spinel_test-unit: no test files found"
|
|
48
|
+
return 1
|
|
49
|
+
end
|
|
50
|
+
|
|
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
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
entries = build_entries(files)
|
|
58
|
+
if entries.empty?
|
|
59
|
+
warn "spinel_test-unit: no tests found"
|
|
60
|
+
return 1
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
Dir.mktmpdir("spinel_test-unit") do |dir|
|
|
64
|
+
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)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def parse(args)
|
|
77
|
+
until args.empty?
|
|
78
|
+
arg = args.shift
|
|
79
|
+
case arg
|
|
80
|
+
when "-h", "--help"
|
|
81
|
+
puts USAGE
|
|
82
|
+
@exit = 0
|
|
83
|
+
return
|
|
84
|
+
when "-n", "--name"
|
|
85
|
+
if args.empty?
|
|
86
|
+
return usage_error("#{arg} requires a value")
|
|
87
|
+
end
|
|
88
|
+
@filter = args.shift
|
|
89
|
+
when "-I"
|
|
90
|
+
return usage_error("-I requires a value") if args.empty?
|
|
91
|
+
|
|
92
|
+
@includes << File.expand_path(args.shift, @root)
|
|
93
|
+
when /\A-I(.+)\z/ then @includes << File.expand_path($1, @root)
|
|
94
|
+
when /\A--name=(.*)\z/ then @filter = $1
|
|
95
|
+
when "--" then @paths.concat(args.shift(args.size))
|
|
96
|
+
when /\A-/ then return usage_error("unknown option: #{arg}")
|
|
97
|
+
else @paths << arg
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def usage_error(message)
|
|
103
|
+
warn "spinel_test-unit: #{message}"
|
|
104
|
+
warn USAGE
|
|
105
|
+
@exit = 2
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def test_files
|
|
109
|
+
paths = @paths.empty? ? ["test"] : @paths
|
|
110
|
+
paths.flat_map do |path|
|
|
111
|
+
full = File.expand_path(path, @root)
|
|
112
|
+
if File.directory?(full)
|
|
113
|
+
# fixtures/ below the given directory are skipped, unless the
|
|
114
|
+
# directory itself is inside fixtures/ (paths relative to @root,
|
|
115
|
+
# so where the project lives does not matter)
|
|
116
|
+
skip = !in_fixtures?(full)
|
|
117
|
+
(Dir.glob(File.join(full, "**", "*_test.rb")) + Dir.glob(File.join(full, "**", "test_*.rb")))
|
|
118
|
+
.reject { |f| skip && in_fixtures?(File.dirname(f)) }
|
|
119
|
+
elsif File.file?(full)
|
|
120
|
+
[full]
|
|
121
|
+
else
|
|
122
|
+
warn "spinel_test-unit: #{path}: no such file or directory"
|
|
123
|
+
[]
|
|
124
|
+
end
|
|
125
|
+
end.uniq.sort
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_entries(files)
|
|
129
|
+
sources = {}
|
|
130
|
+
(context_files + files).uniq.each { |f| sources[f] = File.read(f) }
|
|
131
|
+
add_required_files(sources)
|
|
132
|
+
result = Scanner.scan_files(sources, files)
|
|
133
|
+
result.warnings.each do |file, name, parent|
|
|
134
|
+
warn "spinel_test-unit: #{display(file)}: #{name} inherits from #{parent}, which is not defined in a scanned file; its tests are ignored"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
by_file = result.classes.group_by(&:file)
|
|
138
|
+
order = []
|
|
139
|
+
files.each do |file|
|
|
140
|
+
next unless by_file[file]
|
|
141
|
+
|
|
142
|
+
by_file[file].each { |c| (c.files || [file]).each { |f| order << f } }
|
|
143
|
+
end
|
|
144
|
+
order.uniq.map do |file|
|
|
145
|
+
{ file: file, require: require_name(file), classes: by_file[file] || [] }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Follows string-literal require / require_relative transitively from the
|
|
150
|
+
# already collected files and adds the files found (e.g. a base test
|
|
151
|
+
# class provided by a library on an -I path) as scan-only context.
|
|
152
|
+
def add_required_files(sources)
|
|
153
|
+
gem_lib = File.expand_path("../..", __dir__) + File::SEPARATOR
|
|
154
|
+
dirs = [File.join(@root, "lib"), File.join(@root, "test"), @root] + @includes
|
|
155
|
+
dirs = dirs.select { |d| File.directory?(d) }.uniq
|
|
156
|
+
queue = sources.keys
|
|
157
|
+
until queue.empty?
|
|
158
|
+
file = queue.shift
|
|
159
|
+
Scanner.requires(sources[file]).each do |kind, name|
|
|
160
|
+
found = resolve_require(kind, name, file, dirs)
|
|
161
|
+
next if found.nil? || sources.key?(found) || found.start_with?(gem_lib)
|
|
162
|
+
|
|
163
|
+
begin
|
|
164
|
+
sources[found] = File.read(found)
|
|
165
|
+
rescue SystemCallError
|
|
166
|
+
next
|
|
167
|
+
end
|
|
168
|
+
queue << found
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def resolve_require(kind, name, from, dirs)
|
|
174
|
+
name += ".rb" unless name.end_with?(".rb")
|
|
175
|
+
candidates = if kind == :relative || name.start_with?("./", "../")
|
|
176
|
+
[File.expand_path(name, File.dirname(from))]
|
|
177
|
+
elsif name.start_with?("/")
|
|
178
|
+
[name]
|
|
179
|
+
else
|
|
180
|
+
([File.dirname(from)] + dirs).map { |d| File.expand_path(name, d) }
|
|
181
|
+
end
|
|
182
|
+
candidates.find { |c| File.file?(c) }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def context_files
|
|
186
|
+
dir = File.join(@root, "test")
|
|
187
|
+
Dir.glob(File.join(dir, "**", "*.rb")).reject { |f| in_fixtures?(File.dirname(f)) }.sort
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# True if the directory path, relative to @root, has a fixtures component.
|
|
191
|
+
def in_fixtures?(dir)
|
|
192
|
+
rel = dir.start_with?(@root + File::SEPARATOR) ? dir.delete_prefix(@root + File::SEPARATOR) : dir
|
|
193
|
+
rel.split(File::SEPARATOR).include?("fixtures")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def display(file)
|
|
197
|
+
prefix = @root + File::SEPARATOR
|
|
198
|
+
file.start_with?(prefix) ? file.delete_prefix(prefix) : file
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def require_name(file)
|
|
202
|
+
prefix = @root + File::SEPARATOR
|
|
203
|
+
if file.start_with?(prefix)
|
|
204
|
+
rel = file.delete_prefix(prefix)
|
|
205
|
+
else
|
|
206
|
+
# outside the root: use the name relative to the deepest -I dir
|
|
207
|
+
dir = @includes.select { |d| file.start_with?(d + File::SEPARATOR) }.max_by(&:size)
|
|
208
|
+
rel = dir ? file.delete_prefix(dir + File::SEPARATOR) : File.basename(file)
|
|
209
|
+
end
|
|
210
|
+
rel.delete_suffix(".rb")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def include_dirs(entries)
|
|
214
|
+
gem_lib = File.expand_path("../..", __dir__)
|
|
215
|
+
dirs = [File.join(@root, "lib"), File.join(@root, "test"), @root, gem_lib] + @includes
|
|
216
|
+
entries.each { |e| dirs << File.dirname(e[:file]) unless e[:file].start_with?(@root + File::SEPARATOR) }
|
|
217
|
+
dirs.select { |d| File.directory?(d) }.uniq
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def compile(spinel, main, bin, entries)
|
|
221
|
+
cmd = [spinel] + include_dirs(entries).flat_map { |d| ["-I", d] } + [main, "-o", bin]
|
|
222
|
+
output, status = Open3.capture2e(*cmd)
|
|
223
|
+
unless status.success?
|
|
224
|
+
warn output unless output.empty?
|
|
225
|
+
warn "spinel_test-unit: compilation failed (#{cmd.join(" ")})"
|
|
226
|
+
end
|
|
227
|
+
status.success?
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpinelTest
|
|
4
|
+
module Unit
|
|
5
|
+
module Generator
|
|
6
|
+
# files: [{ require: "test/foo_test", classes: [TestClass, ...] }, ...]
|
|
7
|
+
def self.generate(files)
|
|
8
|
+
out = +"require \"spinel_test/unit\"\n"
|
|
9
|
+
files.each { |f| out << "require #{f[:require].dump}\n" }
|
|
10
|
+
out << ARGV_PARSER
|
|
11
|
+
out << "runner = SpinelTest::Unit::Runner.new(filter)\n"
|
|
12
|
+
files.each do |f|
|
|
13
|
+
f[:classes].each do |klass|
|
|
14
|
+
klass.methods.each do |m|
|
|
15
|
+
out << "t = #{klass.name}.new\n"
|
|
16
|
+
out << "runner.run(#{"#{klass.name}##{m}".dump}, t) { t.#{m} }\n"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
out << "exit runner.finish\n"
|
|
21
|
+
out
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
ARGV_PARSER = <<~RUBY
|
|
25
|
+
filter = ""
|
|
26
|
+
i = 0
|
|
27
|
+
while i < ARGV.length
|
|
28
|
+
if ARGV[i] == "-n" && i + 1 < ARGV.length
|
|
29
|
+
filter = ARGV[i + 1]
|
|
30
|
+
i += 1
|
|
31
|
+
end
|
|
32
|
+
i += 1
|
|
33
|
+
end
|
|
34
|
+
RUBY
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpinelTest
|
|
4
|
+
module Unit
|
|
5
|
+
class Runner
|
|
6
|
+
def initialize(filter = "")
|
|
7
|
+
@filter = filter
|
|
8
|
+
@tests = 0
|
|
9
|
+
@assertions = 0
|
|
10
|
+
@failures = 0
|
|
11
|
+
@errors = 0
|
|
12
|
+
@details = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def selected?(name)
|
|
16
|
+
@filter == "" || name.include?(@filter)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Runs one test: setup, the block (which calls the test method), teardown.
|
|
20
|
+
def run(name, tc)
|
|
21
|
+
return unless selected?(name)
|
|
22
|
+
|
|
23
|
+
@tests += 1
|
|
24
|
+
status = 0
|
|
25
|
+
message = ""
|
|
26
|
+
begin
|
|
27
|
+
tc.setup
|
|
28
|
+
yield
|
|
29
|
+
rescue AssertionFailedError => e
|
|
30
|
+
status = 1
|
|
31
|
+
message = e.message
|
|
32
|
+
rescue Exception => e
|
|
33
|
+
raise e if fatal?(e)
|
|
34
|
+
|
|
35
|
+
status = 2
|
|
36
|
+
message = e.message
|
|
37
|
+
end
|
|
38
|
+
begin
|
|
39
|
+
tc.teardown
|
|
40
|
+
rescue Exception => e
|
|
41
|
+
raise e if fatal?(e)
|
|
42
|
+
|
|
43
|
+
if status == 0
|
|
44
|
+
status = 2
|
|
45
|
+
message = e.message
|
|
46
|
+
else
|
|
47
|
+
message = message + "\nTeardown also raised: " + e.message
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
@assertions += tc.assertions
|
|
51
|
+
if status == 0
|
|
52
|
+
print "."
|
|
53
|
+
elsif status == 1
|
|
54
|
+
print "F"
|
|
55
|
+
@failures += 1
|
|
56
|
+
@details.push("Failure: #{name}\n#{message}")
|
|
57
|
+
else
|
|
58
|
+
print "E"
|
|
59
|
+
@errors += 1
|
|
60
|
+
@details.push("Error: #{name}\n#{message}")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Exceptions that must end the process instead of being reported as a test error.
|
|
65
|
+
def fatal?(e)
|
|
66
|
+
e.is_a?(SystemExit) || e.is_a?(Interrupt)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def passed?
|
|
70
|
+
@failures == 0 && @errors == 0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def summary
|
|
74
|
+
"#{@tests} tests, #{@assertions} assertions, #{@failures} failures, #{@errors} errors"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Prints the report and returns the process exit status.
|
|
78
|
+
def finish
|
|
79
|
+
puts ""
|
|
80
|
+
@details.each do |d|
|
|
81
|
+
puts ""
|
|
82
|
+
puts d
|
|
83
|
+
end
|
|
84
|
+
puts ""
|
|
85
|
+
puts summary
|
|
86
|
+
if @tests == 0
|
|
87
|
+
puts "No tests were run#{@filter == "" ? "" : " (filter: " + @filter + ")"}"
|
|
88
|
+
return 1
|
|
89
|
+
end
|
|
90
|
+
passed? ? 0 : 1
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module SpinelTest
|
|
6
|
+
module Unit
|
|
7
|
+
class Scanner
|
|
8
|
+
VISIBILITY = %i[private protected public].freeze
|
|
9
|
+
BASE_NAMES = ["SpinelTest::Unit::TestCase", "::SpinelTest::Unit::TestCase"].freeze
|
|
10
|
+
|
|
11
|
+
TestClass = Struct.new(:name, :methods, :file, :files, keyword_init: true)
|
|
12
|
+
# superclass is the raw name as written; scope is the lexical namespace it
|
|
13
|
+
# was written in. Both are qualified in resolve, once every class is known.
|
|
14
|
+
ClassInfo = Struct.new(:name, :superclass, :methods, :files, :hidden, :scope)
|
|
15
|
+
Result = Struct.new(:classes, :warnings, keyword_init: true)
|
|
16
|
+
|
|
17
|
+
def self.scan(sources)
|
|
18
|
+
new.scan(sources)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.scan_files(sources, targets)
|
|
22
|
+
new.scan_files(sources, targets)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# String-literal require / require_relative calls in source:
|
|
26
|
+
# [[:require | :relative, "name"], ...]. Unparseable source yields [].
|
|
27
|
+
def self.requires(source)
|
|
28
|
+
found = []
|
|
29
|
+
collect_requires(Prism.parse(source).value, found)
|
|
30
|
+
found
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.collect_requires(node, found)
|
|
34
|
+
if node.is_a?(Prism::CallNode) && node.receiver.nil? &&
|
|
35
|
+
(node.name == :require || node.name == :require_relative)
|
|
36
|
+
arg = node.arguments&.arguments
|
|
37
|
+
if arg && arg.size == 1 && arg[0].is_a?(Prism::StringNode)
|
|
38
|
+
found << [node.name == :require ? :require : :relative, arg[0].unescaped]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
node.compact_child_nodes.each { |child| collect_requires(child, found) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize
|
|
45
|
+
@classes = {}
|
|
46
|
+
@file = nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def scan(sources)
|
|
50
|
+
Array(sources).each { |source| visit(Prism.parse(source).value, []) }
|
|
51
|
+
resolve
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def scan_files(sources, targets)
|
|
55
|
+
sources.each do |path, source|
|
|
56
|
+
@file = path
|
|
57
|
+
visit(Prism.parse(source).value, [])
|
|
58
|
+
end
|
|
59
|
+
classes = resolve(targets).select { |c| targets.include?(c.file) }
|
|
60
|
+
Result.new(classes: classes, warnings: unresolved_warnings(targets))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def visit(node, namespace)
|
|
66
|
+
case node
|
|
67
|
+
when Prism::ModuleNode
|
|
68
|
+
visit(node.body, namespace + [path_name(node.constant_path)]) if node.body
|
|
69
|
+
when Prism::ClassNode
|
|
70
|
+
name = (namespace + [path_name(node.constant_path)]).join("::")
|
|
71
|
+
info = (@classes[name] ||= ClassInfo.new(name, nil, [], []))
|
|
72
|
+
info.files << @file if @file && !info.files.include?(@file)
|
|
73
|
+
if node.superclass
|
|
74
|
+
info.superclass = path_name(node.superclass)
|
|
75
|
+
info.scope = namespace
|
|
76
|
+
end
|
|
77
|
+
collect_methods(node.body, info)
|
|
78
|
+
visit(node.body, name.split("::")) if node.body
|
|
79
|
+
when Prism::ProgramNode
|
|
80
|
+
visit(node.statements, namespace)
|
|
81
|
+
when Prism::StatementsNode
|
|
82
|
+
node.body.each { |child| visit(child, namespace) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def collect_methods(body, info)
|
|
87
|
+
return unless body.is_a?(Prism::StatementsNode)
|
|
88
|
+
|
|
89
|
+
hidden = false
|
|
90
|
+
body.body.each do |stmt|
|
|
91
|
+
if stmt.is_a?(Prism::CallNode) && stmt.receiver.nil? && VISIBILITY.include?(stmt.name)
|
|
92
|
+
if stmt.arguments.nil?
|
|
93
|
+
hidden = stmt.name != :public
|
|
94
|
+
elsif stmt.name != :public
|
|
95
|
+
# private def test_x / private :test_x
|
|
96
|
+
stmt.arguments.arguments.each do |arg|
|
|
97
|
+
name = arg.is_a?(Prism::DefNode) ? arg.name.to_s : (arg.is_a?(Prism::SymbolNode) ? arg.unescaped : nil)
|
|
98
|
+
info.methods.delete(name) if name
|
|
99
|
+
(info.hidden ||= []) << name if name
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
next
|
|
103
|
+
end
|
|
104
|
+
next if hidden
|
|
105
|
+
next unless stmt.is_a?(Prism::DefNode) && stmt.receiver.nil?
|
|
106
|
+
|
|
107
|
+
name = stmt.name.to_s
|
|
108
|
+
next if info.hidden&.include?(name)
|
|
109
|
+
|
|
110
|
+
info.methods << name if name.start_with?("test_") && !info.methods.include?(name)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def path_name(node)
|
|
115
|
+
return nil if node.nil?
|
|
116
|
+
|
|
117
|
+
case node
|
|
118
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
119
|
+
when Prism::ConstantPathNode
|
|
120
|
+
parent = node.parent ? path_name(node.parent) : ""
|
|
121
|
+
"#{parent}::#{node.name}"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Qualified superclass name of info (nil if none), resolved against all
|
|
126
|
+
# scanned classes, so the result does not depend on definition order.
|
|
127
|
+
def superclass_of(info)
|
|
128
|
+
name = info.superclass
|
|
129
|
+
return nil if name.nil?
|
|
130
|
+
return name if BASE_NAMES.include?(name)
|
|
131
|
+
return name.delete_prefix("::") if name.start_with?("::")
|
|
132
|
+
|
|
133
|
+
scope = info.scope || []
|
|
134
|
+
scope.size.downto(0) do |n|
|
|
135
|
+
candidate = (scope.first(n) + [name]).join("::")
|
|
136
|
+
return candidate if @classes.key?(candidate)
|
|
137
|
+
end
|
|
138
|
+
name
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def resolve(targets = nil)
|
|
142
|
+
@classes.values.filter_map do |info|
|
|
143
|
+
chain = ancestry(info)
|
|
144
|
+
next unless chain
|
|
145
|
+
|
|
146
|
+
methods = chain.reverse.flat_map(&:methods).uniq
|
|
147
|
+
next if methods.empty?
|
|
148
|
+
|
|
149
|
+
files = chain.reverse.flat_map(&:files).uniq
|
|
150
|
+
file = (targets && info.files.find { |f| targets.include?(f) }) || info.files.first
|
|
151
|
+
TestClass.new(name: info.name, methods: methods, file: file, files: files)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def unresolved_warnings(targets)
|
|
156
|
+
@classes.values.filter_map do |info|
|
|
157
|
+
next if info.methods.empty? || !info.files.any? { |f| targets.include?(f) }
|
|
158
|
+
next if ancestry(info)
|
|
159
|
+
|
|
160
|
+
parent = missing_parent(info)
|
|
161
|
+
[info.files.find { |f| targets.include?(f) }, info.name, parent] if parent
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def missing_parent(info)
|
|
166
|
+
seen = {}
|
|
167
|
+
current = info
|
|
168
|
+
while current && !seen[current.name]
|
|
169
|
+
seen[current.name] = true
|
|
170
|
+
sup = superclass_of(current)
|
|
171
|
+
return nil if sup.nil? || BASE_NAMES.include?(sup)
|
|
172
|
+
return sup unless @classes[sup]
|
|
173
|
+
|
|
174
|
+
current = @classes[sup]
|
|
175
|
+
end
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def ancestry(info)
|
|
180
|
+
chain = []
|
|
181
|
+
current = info
|
|
182
|
+
seen = {}
|
|
183
|
+
while current
|
|
184
|
+
return nil if seen[current.name]
|
|
185
|
+
|
|
186
|
+
seen[current.name] = true
|
|
187
|
+
chain << current
|
|
188
|
+
sup = superclass_of(current)
|
|
189
|
+
return chain if BASE_NAMES.include?(sup)
|
|
190
|
+
|
|
191
|
+
current = sup && @classes[sup]
|
|
192
|
+
end
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpinelTest
|
|
4
|
+
module Unit
|
|
5
|
+
class AssertionFailedError < StandardError
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
class TestCase
|
|
9
|
+
def initialize
|
|
10
|
+
@assertions = 0
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def assertions
|
|
14
|
+
@assertions
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def setup
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def teardown
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def assert(test, message = nil)
|
|
24
|
+
@assertions += 1
|
|
25
|
+
return true if test
|
|
26
|
+
|
|
27
|
+
raise AssertionFailedError, (message || "Failed assertion, no message given.")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def assert_equal(expected, actual, message = nil)
|
|
31
|
+
@assertions += 1
|
|
32
|
+
return true if expected == actual
|
|
33
|
+
|
|
34
|
+
msg = "Expected: #{expected.inspect}\n Actual: #{actual.inspect}"
|
|
35
|
+
msg = message + "\n" + msg if message
|
|
36
|
+
raise AssertionFailedError, msg
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def assert_nil(v, message = nil)
|
|
40
|
+
@assertions += 1
|
|
41
|
+
return true if v.nil?
|
|
42
|
+
|
|
43
|
+
msg = "Expected nil, got #{v.inspect}"
|
|
44
|
+
msg = message + "\n" + msg if message
|
|
45
|
+
raise AssertionFailedError, msg
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def assert_includes(coll, item, message = nil)
|
|
49
|
+
@assertions += 1
|
|
50
|
+
return true if coll.include?(item)
|
|
51
|
+
|
|
52
|
+
msg = "#{coll.inspect} does not include #{item.inspect}"
|
|
53
|
+
msg = message + "\n" + msg if message
|
|
54
|
+
raise AssertionFailedError, msg
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def assert_raise(klass)
|
|
58
|
+
@assertions += 1
|
|
59
|
+
begin
|
|
60
|
+
yield
|
|
61
|
+
rescue AssertionFailedError => e
|
|
62
|
+
# a failed assertion inside the block is not "the expected error"
|
|
63
|
+
raise e
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
return e if e.is_a?(klass)
|
|
66
|
+
|
|
67
|
+
raise AssertionFailedError, "Expected #{klass} but got #{e.class}"
|
|
68
|
+
end
|
|
69
|
+
raise AssertionFailedError, "Expected #{klass} but nothing was raised"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpinelTest
|
|
4
|
+
module Unit
|
|
5
|
+
# Finds the Spinel compiler.
|
|
6
|
+
#
|
|
7
|
+
# 1. SPINEL (a Spinel checkout, for example)
|
|
8
|
+
# 2. spinel on PATH
|
|
9
|
+
# 3. `mise which spinel` (mise is installed but not activated)
|
|
10
|
+
module Toolchain
|
|
11
|
+
def self.spinel
|
|
12
|
+
env = ENV["SPINEL"]
|
|
13
|
+
return env unless env.nil? || env.empty?
|
|
14
|
+
|
|
15
|
+
find_on_path("spinel") || mise_which("spinel")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.find_on_path(name)
|
|
19
|
+
ENV["PATH"].to_s.split(File::PATH_SEPARATOR).map { |d| File.join(d, name) }
|
|
20
|
+
.find { |f| File.file?(f) && File.executable?(f) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.mise_which(name)
|
|
24
|
+
return nil unless find_on_path("mise")
|
|
25
|
+
|
|
26
|
+
out = IO.popen(["mise", "which", name], err: File::NULL, &:read).strip
|
|
27
|
+
$?.success? && !out.empty? ? out : nil
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/mise.toml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# The tools for developing spinel_test-unit; `bin/setup` installs them.
|
|
2
|
+
#
|
|
3
|
+
# To use a Spinel checkout instead, point SPINEL at it in a
|
|
4
|
+
# mise.local.toml (git-ignored); SPINEL wins over spinel on PATH:
|
|
5
|
+
#
|
|
6
|
+
# [env]
|
|
7
|
+
# SPINEL = "{{ env.HOME }}/src/github.com/matz/spinel/spinel"
|
|
8
|
+
|
|
9
|
+
[plugins]
|
|
10
|
+
spinel = "https://github.com/y-yagi/mise-spinel"
|
|
11
|
+
|
|
12
|
+
[tools]
|
|
13
|
+
# A commit of matz/spinel (mise-spinel builds any git ref from source).
|
|
14
|
+
spinel = { version = "b5204b507" }
|
metadata
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: spinel_test-unit
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yuji Yaginuma
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: prism
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.3'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '2.0'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '1.3'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '2.0'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: test-unit
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '0'
|
|
39
|
+
type: :development
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '0'
|
|
46
|
+
email:
|
|
47
|
+
- yuuji.yaginuma@gmail.com
|
|
48
|
+
executables:
|
|
49
|
+
- spinel_test-unit
|
|
50
|
+
extensions: []
|
|
51
|
+
extra_rdoc_files: []
|
|
52
|
+
files:
|
|
53
|
+
- CODE_OF_CONDUCT.md
|
|
54
|
+
- LICENSE.txt
|
|
55
|
+
- README.md
|
|
56
|
+
- Rakefile
|
|
57
|
+
- exe/spinel_test-unit
|
|
58
|
+
- lib/spinel_test/unit.rb
|
|
59
|
+
- lib/spinel_test/unit/cli.rb
|
|
60
|
+
- lib/spinel_test/unit/generator.rb
|
|
61
|
+
- lib/spinel_test/unit/runner.rb
|
|
62
|
+
- lib/spinel_test/unit/scanner.rb
|
|
63
|
+
- lib/spinel_test/unit/test_case.rb
|
|
64
|
+
- lib/spinel_test/unit/toolchain.rb
|
|
65
|
+
- lib/spinel_test/unit/version.rb
|
|
66
|
+
- mise.toml
|
|
67
|
+
homepage: https://github.com/y-yagi/spinel_test-unit
|
|
68
|
+
licenses:
|
|
69
|
+
- MIT
|
|
70
|
+
metadata:
|
|
71
|
+
homepage_uri: https://github.com/y-yagi/spinel_test-unit
|
|
72
|
+
source_code_uri: https://github.com/y-yagi/spinel_test-unit
|
|
73
|
+
rdoc_options: []
|
|
74
|
+
require_paths:
|
|
75
|
+
- lib
|
|
76
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: 3.2.0
|
|
81
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
82
|
+
requirements:
|
|
83
|
+
- - ">="
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: '0'
|
|
86
|
+
requirements: []
|
|
87
|
+
rubygems_version: 4.0.17
|
|
88
|
+
specification_version: 4
|
|
89
|
+
summary: An xUnit-style test framework for Spinel.
|
|
90
|
+
test_files: []
|