featureparity 0.0.27 → 0.0.29
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/lib/fp/cli.rb +14 -2
- data/lib/fp/client.rb +79 -2
- data/lib/fp/commands/analyze.rb +17 -0
- data/lib/fp/commands/base.rb +16 -0
- data/lib/fp/commands/check.rb +38 -4
- data/lib/fp/commands/ci_report.rb +2 -2
- data/lib/fp/commands/comment.rb +1 -1
- data/lib/fp/commands/comments.rb +146 -0
- data/lib/fp/commands/coverage.rb +149 -0
- data/lib/fp/commands/doctor.rb +228 -0
- data/lib/fp/commands/help.rb +41 -1
- data/lib/fp/commands/history.rb +16 -1
- data/lib/fp/commands/init.rb +181 -117
- data/lib/fp/commands/list.rb +58 -4
- data/lib/fp/commands/mark.rb +167 -0
- data/lib/fp/commands/projects.rb +6 -1
- data/lib/fp/commands/propose.rb +435 -53
- data/lib/fp/commands/propose_change.rb +13 -7
- data/lib/fp/commands/question.rb +137 -0
- data/lib/fp/commands/report.rb +40 -4
- data/lib/fp/commands/run.rb +147 -0
- data/lib/fp/commands/show.rb +41 -3
- data/lib/fp/commands/spot_check.rb +458 -0
- data/lib/fp/commands/tutorial.rb +95 -0
- data/lib/fp/commands/whoami.rb +2 -0
- data/lib/fp/commands.rb +8 -0
- data/lib/fp/config.rb +12 -0
- data/lib/fp/junit.rb +74 -11
- data/lib/fp/repo_detector.rb +488 -0
- data/lib/fp/requirement_lint.rb +105 -0
- data/lib/fp/rspec_json.rb +54 -0
- data/lib/fp/tutorial/hexhaven/AGENTS.md +5 -0
- data/lib/fp/tutorial/hexhaven/README.md +29 -0
- data/lib/fp/tutorial/hexhaven/lib/hexhaven.rb +36 -0
- data/lib/fp/tutorial/hexhaven/test/junit_reporter.rb +134 -0
- data/lib/fp/tutorial/hexhaven/test/rules_test.rb +36 -0
- data/lib/fp/tutorial/hexhaven/test/test_helper.rb +6 -0
- data/lib/fp/version.rb +1 -1
- data/lib/fp.rb +1 -0
- data/skills/fp/SKILL.md +244 -105
- data/skills/fp/references/cli.md +304 -3
- metadata +19 -2
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
# Local wording checks for a requirement before it is written (Fizzy #1786).
|
|
5
|
+
#
|
|
6
|
+
# `fp analyze` reviews a requirement already in the catalog; an agent writing a batch
|
|
7
|
+
# of drafts wants the cheap mechanical checks *before* anything is created, so a human
|
|
8
|
+
# can review the batch with `fp propose --dry-run` and nothing lands half-finished.
|
|
9
|
+
# These are the rules from the requirement-writing guidance that a string can be
|
|
10
|
+
# tested against: a promise for a title, no filler, a stated outcome, one rule, an
|
|
11
|
+
# example, and no numbers nobody decided. Everything here is a warning — the human
|
|
12
|
+
# product owner decides scope, and a heuristic doesn't get to veto a draft.
|
|
13
|
+
module RequirementLint
|
|
14
|
+
# Titles that name the work rather than the promise.
|
|
15
|
+
WORK_VERBS = %w[implement add build create refactor fix support enable integrate migrate].freeze
|
|
16
|
+
|
|
17
|
+
# Commentary about the requirement instead of the requirement.
|
|
18
|
+
COMMENTARY_OPENINGS = [
|
|
19
|
+
'this feature', 'this requirement', 'the goal is', 'users should be able to',
|
|
20
|
+
'the system should', 'we need to', 'we should', 'as a user'
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
# Phrases that sell instead of saying anything a reviewer can check.
|
|
24
|
+
FILLER = [
|
|
25
|
+
'seamless', 'robust', 'user-friendly', 'intuitive', 'best-in-class', 'world-class',
|
|
26
|
+
'leverage', 'streamline', 'cutting-edge', 'state-of-the-art', 'ensure a smooth',
|
|
27
|
+
'enhance the user experience', 'improve the user experience', 'delightful'
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
Finding = Struct.new(:field, :message, keyword_init: true)
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# spec: a hash with :slug, :title, :why, :acceptance, :source_type (string keys or
|
|
35
|
+
# symbol keys). Returns [Finding, ...] — empty when nothing stood out.
|
|
36
|
+
def check(spec)
|
|
37
|
+
get = ->(k) { (spec[k] || spec[k.to_s]).to_s.strip }
|
|
38
|
+
title = get.call(:title)
|
|
39
|
+
why = get.call(:why)
|
|
40
|
+
acceptance = get.call(:acceptance)
|
|
41
|
+
findings = []
|
|
42
|
+
|
|
43
|
+
first_word = title.split(/\s+/).first.to_s.downcase
|
|
44
|
+
if WORK_VERBS.include?(first_word)
|
|
45
|
+
findings << Finding.new(field: 'title',
|
|
46
|
+
message: "starts with '#{first_word}' — name the promise a user gets, not the work " \
|
|
47
|
+
'(e.g. "Dispatchers see current shipment status", not "Implement status polling")')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
{ 'title' => title, 'why' => why, 'acceptance' => acceptance }.each do |field, text|
|
|
51
|
+
opening = COMMENTARY_OPENINGS.find { |o| text.downcase.start_with?(o) }
|
|
52
|
+
if opening
|
|
53
|
+
findings << Finding.new(field: field,
|
|
54
|
+
message: "opens with \"#{opening}\" — state the promise itself, with the real actor as the subject")
|
|
55
|
+
end
|
|
56
|
+
filler = FILLER.select { |f| text.downcase.include?(f) }
|
|
57
|
+
unless filler.empty?
|
|
58
|
+
findings << Finding.new(field: field,
|
|
59
|
+
message: "uses filler (#{filler.join(', ')}) — say what a reviewer could observe instead")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
findings << Finding.new(field: 'why', message: 'is missing — who needs this, and what goes wrong without it?') if why.empty?
|
|
64
|
+
|
|
65
|
+
if acceptance.empty?
|
|
66
|
+
findings << Finding.new(field: 'acceptance', message: 'is missing — a reviewer needs a rule they can check')
|
|
67
|
+
else
|
|
68
|
+
rule, example = split_example(acceptance)
|
|
69
|
+
if example.nil?
|
|
70
|
+
findings << Finding.new(field: 'acceptance',
|
|
71
|
+
message: "has no 'Example: ...' — add one concrete case that would pass the rule")
|
|
72
|
+
end
|
|
73
|
+
sentences = rule.split(/(?<=[.!?])\s+(?=[A-Z])/).reject { |s| s.strip.empty? }
|
|
74
|
+
if sentences.length > 2
|
|
75
|
+
findings << Finding.new(field: 'acceptance',
|
|
76
|
+
message: "states #{sentences.length} sentences of rule — if they could pass or fail " \
|
|
77
|
+
'separately, split them into their own requirements')
|
|
78
|
+
end
|
|
79
|
+
if rule.match?(/\d/)
|
|
80
|
+
findings << Finding.new(field: 'acceptance',
|
|
81
|
+
message: 'contains a number — keep it only if the source decided it; never invent a limit, price or timeout')
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if title.downcase == why.downcase && !why.empty?
|
|
86
|
+
findings << Finding.new(field: 'why', message: 'repeats the title — say why the outcome matters')
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if get.call(:source_type).empty?
|
|
90
|
+
findings << Finding.new(field: 'source',
|
|
91
|
+
message: 'none given — pass --source-type/--source-url (repo_scan with --source-ref when you read it from the code)')
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
findings
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# [rule, example_or_nil] — the example is whatever follows "Example:".
|
|
98
|
+
def split_example(acceptance)
|
|
99
|
+
idx = acceptance =~ /\bexample:/i
|
|
100
|
+
return [acceptance, nil] if idx.nil?
|
|
101
|
+
|
|
102
|
+
[acceptance[0...idx].strip, acceptance[idx..].sub(/\Aexample:\s*/i, '').strip]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Fp
|
|
6
|
+
module JUnit
|
|
7
|
+
# RSpec's built-in JSON report (`rspec --format json --out rspec.json`), read into the
|
|
8
|
+
# same TestCase structs a JUnit report produces (Fizzy #1786). Getting JUnit out of
|
|
9
|
+
# RSpec takes the `rspec_junit_formatter` gem, which most Gemfiles don't carry, so a
|
|
10
|
+
# first `fp report` meant editing the project before it could report anything. The
|
|
11
|
+
# JSON formatter ships with rspec-core and carries everything binding needs: the file,
|
|
12
|
+
# the example's line, its full description and its status.
|
|
13
|
+
module RspecJson
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Content sniffing rather than a flag or a file extension: every `--junit` option
|
|
17
|
+
# accepts it unchanged, and an XML report can never start with `{`.
|
|
18
|
+
def json?(content)
|
|
19
|
+
content.lstrip.start_with?('{')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def parse(content, path = 'report')
|
|
23
|
+
data = JSON.parse(content)
|
|
24
|
+
examples = data.is_a?(Hash) ? data['examples'] : nil
|
|
25
|
+
unless examples.is_a?(Array)
|
|
26
|
+
raise ArgumentError,
|
|
27
|
+
"#{path} is JSON but not RSpec's --format json output (no \"examples\" list). " \
|
|
28
|
+
'Pass a JUnit XML report or `rspec --format json --out <file>`.'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
examples.map { |example| testcase(example) }
|
|
32
|
+
rescue JSON::ParserError => e
|
|
33
|
+
raise ArgumentError, "Could not parse JSON report (#{path}): #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# passed → present; pending (RSpec's `skip`/`pending`/`xit`) → skipped, which
|
|
37
|
+
# agents file as a stub, as with a JUnit <skipped/>; failed → failed, which only
|
|
38
|
+
# CI acts on.
|
|
39
|
+
def testcase(example)
|
|
40
|
+
file = example['file_path'].to_s
|
|
41
|
+
status = example['status'].to_s
|
|
42
|
+
TestCase.new(
|
|
43
|
+
name: (example['full_description'] || example['description']).to_s,
|
|
44
|
+
classname: file,
|
|
45
|
+
file: file.empty? ? nil : file,
|
|
46
|
+
line: example['line_number']&.to_i,
|
|
47
|
+
status: status == 'pending' ? :skipped : :present,
|
|
48
|
+
failed: status == 'failed',
|
|
49
|
+
attachments: []
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Hexhaven: your FeatureParity practice table
|
|
2
|
+
|
|
3
|
+
Hexhaven's game server: the referee that checks every move. Rules in `lib/`, tests in
|
|
4
|
+
`test/`. Plain Ruby; nothing to install.
|
|
5
|
+
|
|
6
|
+
1. Run the tests:
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
ruby -Ilib test/rules_test.rb
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
2. Put this above the last test in `test/rules_test.rb`, or ask your agent to
|
|
13
|
+
(`AGENTS.md` tells it how):
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# fp:tallest_tower_scores_two_points@server
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
3. Report it:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
JUNIT_OUT=junit.xml ruby -Ilib test/rules_test.rb
|
|
23
|
+
fp report --junit junit.xml --repo hexhaven/server
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The tallest-tower cell on Server turns from **missing** to **present**. Back to the
|
|
27
|
+
tutorial.
|
|
28
|
+
|
|
29
|
+
Made a mess? `fp tutorial --restart`
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Hexhaven — a made-up board game, and the rules its game server referees.
|
|
4
|
+
#
|
|
5
|
+
# Every rule here is a requirement in your FeatureParity tutorial project. The server checks
|
|
6
|
+
# every move; Web, iOS and Android each show the same rules on their own, and FeatureParity
|
|
7
|
+
# proves all four agree.
|
|
8
|
+
module Hexhaven
|
|
9
|
+
DIE = (1..6).freeze
|
|
10
|
+
HARBOR_RATE = 3
|
|
11
|
+
TALLEST_TOWER_MINIMUM = 5
|
|
12
|
+
TALLEST_TOWER_POINTS = 2
|
|
13
|
+
|
|
14
|
+
# A turn starts with two six-sided dice.
|
|
15
|
+
def self.roll(rng = Random.new)
|
|
16
|
+
[rng.rand(DIE), rng.rand(DIE)]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# At a harbor, three of one good buy one of any other.
|
|
20
|
+
def self.harbor_trade(hand, give:, get:)
|
|
21
|
+
raise ArgumentError, "a harbor trade needs #{HARBOR_RATE} #{give}" if hand.fetch(give, 0) < HARBOR_RATE
|
|
22
|
+
|
|
23
|
+
hand.merge(give => hand[give] - HARBOR_RATE, get => hand.fetch(get, 0) + 1)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Who holds the tallest-tower bonus, given each player's tallest tower in floors. The
|
|
27
|
+
# first tower of five floors or more takes it; only a strictly taller one steals it.
|
|
28
|
+
def self.tallest_tower(towers, holder: nil)
|
|
29
|
+
contenders = towers.select { |_, floors| floors >= TALLEST_TOWER_MINIMUM }
|
|
30
|
+
return nil if contenders.empty?
|
|
31
|
+
|
|
32
|
+
best = contenders.values.max
|
|
33
|
+
holder = nil unless holder && towers.fetch(holder, 0) == best
|
|
34
|
+
holder || contenders.key(best)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A dependency-free JUnit XML reporter for Minitest, written only when JUNIT_OUT is set.
|
|
4
|
+
#
|
|
5
|
+
# `fp report --junit` reads the report, opens each test's file, and finds the fp:<slug>
|
|
6
|
+
# marker above it. That is why every testcase carries the file and line it was declared
|
|
7
|
+
# on.
|
|
8
|
+
|
|
9
|
+
require 'minitest'
|
|
10
|
+
require 'fileutils'
|
|
11
|
+
|
|
12
|
+
module MinitestJUnitReporter
|
|
13
|
+
class Reporter < Minitest::AbstractReporter
|
|
14
|
+
def initialize(output_path, base_dir)
|
|
15
|
+
super()
|
|
16
|
+
@output_path = output_path
|
|
17
|
+
@base_dir = File.expand_path(base_dir)
|
|
18
|
+
@results = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def record(result)
|
|
22
|
+
@results << result
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def report
|
|
26
|
+
FileUtils.mkdir_p(File.dirname(@output_path))
|
|
27
|
+
File.write(@output_path, xml)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def xml
|
|
33
|
+
failures = @results.count { |r| r.failures.any? { |f| !f.is_a?(Minitest::Skip) } }
|
|
34
|
+
skips = @results.count { |r| r.failures.any? { |f| f.is_a?(Minitest::Skip) } }
|
|
35
|
+
time = @results.sum(&:time)
|
|
36
|
+
|
|
37
|
+
body = @results.map { |r| testcase_xml(r) }.join
|
|
38
|
+
|
|
39
|
+
<<~XML
|
|
40
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
41
|
+
<testsuites>
|
|
42
|
+
<testsuite name="minitest" tests="#{@results.size}" failures="#{failures}" errors="0" skipped="#{skips}" time="#{format('%.6f', time)}">
|
|
43
|
+
#{body} </testsuite>
|
|
44
|
+
</testsuites>
|
|
45
|
+
XML
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def testcase_xml(result)
|
|
49
|
+
file, line = declaring_location(result)
|
|
50
|
+
attrs = [
|
|
51
|
+
%(name="#{esc(result.name)}"),
|
|
52
|
+
%(classname="#{esc(result.klass)}"),
|
|
53
|
+
%(file="#{esc(relative_to_base(file))}"),
|
|
54
|
+
# `line` is what lets `fp` bind each marker to the specific test below it. Without
|
|
55
|
+
# it fp can only handle files with exactly one marker.
|
|
56
|
+
(%(line="#{line}") if line),
|
|
57
|
+
%(time="#{format('%.6f', result.time)}")
|
|
58
|
+
].compact.join(' ')
|
|
59
|
+
|
|
60
|
+
skip = result.failures.find { |f| f.is_a?(Minitest::Skip) }
|
|
61
|
+
real_failures = result.failures.reject { |f| f.is_a?(Minitest::Skip) }
|
|
62
|
+
|
|
63
|
+
if skip
|
|
64
|
+
%( <testcase #{attrs}>\n <skipped message="#{esc(skip.message)}"/>\n </testcase>\n)
|
|
65
|
+
elsif real_failures.any?
|
|
66
|
+
inner = real_failures.map do |f|
|
|
67
|
+
tag = f.is_a?(Minitest::UnexpectedError) ? 'error' : 'failure'
|
|
68
|
+
%( <#{tag} message="#{esc(f.message)}"/>\n)
|
|
69
|
+
end.join
|
|
70
|
+
%( <testcase #{attrs}>\n#{inner} </testcase>\n)
|
|
71
|
+
else
|
|
72
|
+
%( <testcase #{attrs}/>\n)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Where the test method is declared, as [path, line]. The path is what `fp` opens to
|
|
77
|
+
# look for markers and the line is what binds a marker to its test, so this matters
|
|
78
|
+
# more than the rest of the report.
|
|
79
|
+
def declaring_location(result)
|
|
80
|
+
# Minitest 5.18+ exposes source_location on the result.
|
|
81
|
+
return result.source_location if result.respond_to?(:source_location)
|
|
82
|
+
|
|
83
|
+
# Older minitest: ask the test class for the method's definition site.
|
|
84
|
+
klass = Object.const_get(result.klass.to_s)
|
|
85
|
+
klass.instance_method(result.name).source_location || [nil, nil]
|
|
86
|
+
rescue StandardError
|
|
87
|
+
[nil, nil]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def relative_to_base(path)
|
|
91
|
+
return '' unless path
|
|
92
|
+
|
|
93
|
+
absolute = File.expand_path(path)
|
|
94
|
+
relative = absolute.delete_prefix("#{@base_dir}/")
|
|
95
|
+
relative == absolute ? path : relative
|
|
96
|
+
rescue StandardError
|
|
97
|
+
''
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def esc(str)
|
|
101
|
+
str.to_s
|
|
102
|
+
.gsub('&', '&').gsub('<', '<').gsub('>', '>')
|
|
103
|
+
.gsub('"', '"').gsub("'", ''')
|
|
104
|
+
.gsub(/[^[:print:]\t\n]/, '')
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
class << self
|
|
109
|
+
# Register as a Minitest plugin. Minitest calls plugin_<name>_init while building its
|
|
110
|
+
# composite reporter, which is the only point where a reporter can still be added.
|
|
111
|
+
# No-op unless JUNIT_OUT is set, so ordinary test runs are unaffected.
|
|
112
|
+
def install!
|
|
113
|
+
path = ENV['JUNIT_OUT']
|
|
114
|
+
return if path.nil? || path.strip.empty?
|
|
115
|
+
return if @installed
|
|
116
|
+
|
|
117
|
+
@installed = true
|
|
118
|
+
@output_path = File.expand_path(path)
|
|
119
|
+
@base_dir = ENV['JUNIT_BASE_DIR'] || Dir.pwd
|
|
120
|
+
|
|
121
|
+
Minitest.extensions << 'junit_xml' unless Minitest.extensions.include?('junit_xml')
|
|
122
|
+
|
|
123
|
+
Minitest.singleton_class.define_method(:plugin_junit_xml_init) do |_options|
|
|
124
|
+
Minitest.reporter << MinitestJUnitReporter.build_reporter
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_reporter
|
|
129
|
+
Reporter.new(@output_path, @base_dir)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
MinitestJUnitReporter.install!
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'test_helper'
|
|
4
|
+
|
|
5
|
+
# The referee's rules, each bound to the requirement it proves by the fp: comment directly
|
|
6
|
+
# above it. `@server` pins the surface, since this repo is the game server.
|
|
7
|
+
class RulesTest < Minitest::Test
|
|
8
|
+
# fp:turn_starts_with_two_dice@server
|
|
9
|
+
def test_a_turn_rolls_two_six_sided_dice
|
|
10
|
+
100.times do
|
|
11
|
+
dice = Hexhaven.roll
|
|
12
|
+
assert_equal 2, dice.size
|
|
13
|
+
dice.each { |die| assert_includes 1..6, die }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# fp:harbor_trades_three_for_one@server
|
|
18
|
+
def test_three_wool_become_one_brick_at_a_harbor
|
|
19
|
+
hand = Hexhaven.harbor_trade({ wool: 3 }, give: :wool, get: :brick)
|
|
20
|
+
|
|
21
|
+
assert_equal({ wool: 0, brick: 1 }, hand)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# fp:harbor_trades_three_for_one@server
|
|
25
|
+
def test_a_harbor_refuses_fewer_than_three
|
|
26
|
+
assert_raises(ArgumentError) { Hexhaven.harbor_trade({ wool: 2 }, give: :wool, get: :brick) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# 👉 Your move: put this line (uncommented) above the test, or ask your agent to.
|
|
30
|
+
# # fp:tallest_tower_scores_two_points@server
|
|
31
|
+
def test_a_six_floor_tower_takes_the_bonus_from_a_five_floor_tower
|
|
32
|
+
assert_equal :ada, Hexhaven.tallest_tower({ ada: 5, ben: 3 })
|
|
33
|
+
assert_equal :ben, Hexhaven.tallest_tower({ ada: 5, ben: 6 }, holder: :ada)
|
|
34
|
+
assert_nil Hexhaven.tallest_tower({ ada: 4, ben: 4 })
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/fp/version.rb
CHANGED
data/lib/fp.rb
CHANGED