tui-td 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/CHANGELOG.md +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +479 -0
- data/bin/tui-td +9 -0
- data/lib/tui_td/ansi_parser.rb +405 -0
- data/lib/tui_td/cli.rb +232 -0
- data/lib/tui_td/driver.rb +188 -0
- data/lib/tui_td/html_renderer.rb +228 -0
- data/lib/tui_td/matchers.rb +72 -0
- data/lib/tui_td/mcp/server.rb +463 -0
- data/lib/tui_td/screenshot.rb +271 -0
- data/lib/tui_td/state.rb +111 -0
- data/lib/tui_td/test_runner.rb +178 -0
- data/lib/tui_td/version.rb +5 -0
- data/lib/tui_td.rb +25 -0
- metadata +159 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pty"
|
|
4
|
+
require "io/console"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module TUITD
|
|
8
|
+
# Drives a TUI application in a pseudo-terminal (PTY).
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# driver = Driver.new("my_tui_app")
|
|
12
|
+
# driver.start
|
|
13
|
+
# driver.wait_for_text("> ")
|
|
14
|
+
# driver.send("Write hello\n")
|
|
15
|
+
# state = driver.state_json # => structured JSON for AI
|
|
16
|
+
# driver.screenshot("out.png")
|
|
17
|
+
# driver.close
|
|
18
|
+
#
|
|
19
|
+
class Driver
|
|
20
|
+
attr_reader :command, :state
|
|
21
|
+
|
|
22
|
+
def initialize(command, rows: 40, cols: 120, timeout: 30, chdir: nil)
|
|
23
|
+
@command = command
|
|
24
|
+
@rows = rows
|
|
25
|
+
@cols = cols
|
|
26
|
+
@timeout = timeout
|
|
27
|
+
@chdir = chdir
|
|
28
|
+
@state = nil
|
|
29
|
+
@stdin = nil
|
|
30
|
+
@stdout = nil
|
|
31
|
+
@wait_thr = nil
|
|
32
|
+
@output_buffer = +""
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Start the TUI application in a PTY
|
|
36
|
+
def start
|
|
37
|
+
env = { "TERM" => "xterm-256color", "COLUMNS" => @cols.to_s, "LINES" => @rows.to_s }
|
|
38
|
+
spawn_opts = {}
|
|
39
|
+
spawn_opts[:chdir] = @chdir if @chdir
|
|
40
|
+
|
|
41
|
+
@stdout, @stdin, @pid = PTY.spawn(env, @command, spawn_opts)
|
|
42
|
+
@stdout.winsize = [@rows, @cols] # Set PTY window size for TUIs that check winsize
|
|
43
|
+
@wait_thr = Process.detach(@pid)
|
|
44
|
+
|
|
45
|
+
# Read until initial output stabilizes
|
|
46
|
+
wait_for_stable
|
|
47
|
+
refresh_state!
|
|
48
|
+
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Send text to the TUI
|
|
53
|
+
def send(text)
|
|
54
|
+
ensure_running!
|
|
55
|
+
@stdin&.print(text)
|
|
56
|
+
@stdin&.flush
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Send keys (escape sequences, control characters)
|
|
61
|
+
def send_keys(keys)
|
|
62
|
+
ensure_running!
|
|
63
|
+
case keys
|
|
64
|
+
when :enter then send("\r")
|
|
65
|
+
when :tab then send("\t")
|
|
66
|
+
when :escape then send("\e")
|
|
67
|
+
when :up then send("\e[A")
|
|
68
|
+
when :down then send("\e[B")
|
|
69
|
+
when :left then send("\e[D")
|
|
70
|
+
when :right then send("\e[C")
|
|
71
|
+
when :backspace then send("\u007f")
|
|
72
|
+
when :ctrl_c then send("\u0003")
|
|
73
|
+
when :ctrl_d then send("\u0004")
|
|
74
|
+
else send(keys.to_s)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Wait until output contains the given text
|
|
79
|
+
def wait_for_text(text)
|
|
80
|
+
deadline = monotonic + @timeout
|
|
81
|
+
loop do
|
|
82
|
+
raise TimeoutError, "Timeout waiting for: #{text.inspect}" if monotonic > deadline
|
|
83
|
+
read_available!
|
|
84
|
+
break if @output_buffer.include?(text)
|
|
85
|
+
sleep 0.05
|
|
86
|
+
end
|
|
87
|
+
refresh_state!
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Wait for output to stabilize (no new data for N milliseconds)
|
|
91
|
+
def wait_for_stable(stable_ms: 300)
|
|
92
|
+
deadline = monotonic + @timeout
|
|
93
|
+
last_change = monotonic
|
|
94
|
+
|
|
95
|
+
loop do
|
|
96
|
+
raise TimeoutError, "Timeout waiting for stable output" if monotonic > deadline
|
|
97
|
+
|
|
98
|
+
if read_available!
|
|
99
|
+
last_change = monotonic
|
|
100
|
+
elsif (monotonic - last_change) * 1000 >= stable_ms
|
|
101
|
+
break
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
sleep 0.05
|
|
105
|
+
end
|
|
106
|
+
refresh_state!
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Wait until the process finishes
|
|
110
|
+
def wait_for_exit
|
|
111
|
+
@wait_thr&.value
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Get the terminal output (raw ANSI + text)
|
|
115
|
+
def raw_output
|
|
116
|
+
read_available!
|
|
117
|
+
@output_buffer
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Get structured terminal state as a Hash
|
|
121
|
+
def state_data
|
|
122
|
+
refresh_state! if @state.nil?
|
|
123
|
+
@state
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Get structured terminal state as JSON string
|
|
127
|
+
def state_json(pretty: false)
|
|
128
|
+
state_data
|
|
129
|
+
pretty ? JSON.pretty_generate(@state) : JSON.generate(@state)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Capture a PNG screenshot of the current terminal state
|
|
133
|
+
def screenshot(output_path)
|
|
134
|
+
state_data
|
|
135
|
+
Screenshot.new(@state).render(output_path)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Close the driver and clean up
|
|
139
|
+
def close
|
|
140
|
+
# Kill the process if still running
|
|
141
|
+
if @pid
|
|
142
|
+
begin
|
|
143
|
+
if Process.waitpid(@pid, Process::WNOHANG).nil?
|
|
144
|
+
Process.kill("TERM", @pid) rescue nil
|
|
145
|
+
sleep 0.05
|
|
146
|
+
Process.kill("KILL", @pid) rescue nil
|
|
147
|
+
end
|
|
148
|
+
rescue Errno::ECHILD
|
|
149
|
+
# Already reaped by Process.detach
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
@stdin&.close rescue nil
|
|
153
|
+
@stdout&.close rescue nil
|
|
154
|
+
@stdin = @stdout = @pid = nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def ensure_running!
|
|
160
|
+
raise Error, "Driver not started. Call #start first." if @stdin.nil?
|
|
161
|
+
raise Error, "Process exited (status: #{@wait_thr&.value&.exitstatus})" unless @wait_thr&.alive?
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def read_available!
|
|
165
|
+
return false unless @stdout
|
|
166
|
+
|
|
167
|
+
ready, = IO.select([@stdout], nil, nil, 0.01)
|
|
168
|
+
return false unless ready
|
|
169
|
+
|
|
170
|
+
data = @stdout.readpartial(4096)
|
|
171
|
+
@output_buffer << data
|
|
172
|
+
true
|
|
173
|
+
rescue EOFError
|
|
174
|
+
false
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def refresh_state!
|
|
178
|
+
@state = ANSIParser.parse(@output_buffer, @rows, @cols)
|
|
179
|
+
@state[:raw] = @output_buffer.dup
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def monotonic
|
|
183
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
class TimeoutError < Error; end
|
|
188
|
+
end
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TUITD
|
|
4
|
+
# Renders terminal state as a self-contained HTML document.
|
|
5
|
+
# Faithfully reproduces what a TUI application shows — colors, styles,
|
|
6
|
+
# cursor position — so an LLM or human can "see" the terminal.
|
|
7
|
+
class HtmlRenderer
|
|
8
|
+
ANSI_RGB = {
|
|
9
|
+
"black" => [0x00, 0x00, 0x00],
|
|
10
|
+
"red" => [0xAA, 0x00, 0x00],
|
|
11
|
+
"green" => [0x00, 0xAA, 0x00],
|
|
12
|
+
"yellow" => [0xAA, 0x55, 0x00],
|
|
13
|
+
"blue" => [0x00, 0x00, 0xAA],
|
|
14
|
+
"magenta" => [0xAA, 0x00, 0xAA],
|
|
15
|
+
"cyan" => [0x00, 0xAA, 0xAA],
|
|
16
|
+
"white" => [0xAA, 0xAA, 0xAA],
|
|
17
|
+
"bright_black" => [0x55, 0x55, 0x55],
|
|
18
|
+
"bright_red" => [0xFF, 0x55, 0x55],
|
|
19
|
+
"bright_green" => [0x55, 0xFF, 0x55],
|
|
20
|
+
"bright_yellow" => [0xFF, 0xFF, 0x55],
|
|
21
|
+
"bright_blue" => [0x55, 0x55, 0xFF],
|
|
22
|
+
"bright_magenta"=> [0xFF, 0x55, 0xFF],
|
|
23
|
+
"bright_cyan" => [0x55, 0xFF, 0xFF],
|
|
24
|
+
"bright_white" => [0xFF, 0xFF, 0xFF],
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
CUBE = [0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF].freeze
|
|
28
|
+
|
|
29
|
+
ANSI_INDEX = %w[
|
|
30
|
+
black red green yellow blue magenta cyan white
|
|
31
|
+
bright_black bright_red bright_green bright_yellow
|
|
32
|
+
bright_blue bright_magenta bright_cyan bright_white
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
DEFAULT_FG = [0xC0, 0xC0, 0xC0].freeze
|
|
36
|
+
DEFAULT_BG = [0x00, 0x00, 0x00].freeze
|
|
37
|
+
|
|
38
|
+
def initialize(state)
|
|
39
|
+
@state = state
|
|
40
|
+
@rows = _dig(state, :size, :rows) || 40
|
|
41
|
+
@cols = _dig(state, :size, :cols) || 120
|
|
42
|
+
@grid = state[:rows] || state["rows"] || []
|
|
43
|
+
@cursor = state[:cursor] || state["cursor"] || { row: 0, col: 0 }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Return HTML string
|
|
47
|
+
def to_html
|
|
48
|
+
css = render_css
|
|
49
|
+
body = render_body
|
|
50
|
+
<<~HTML
|
|
51
|
+
<!DOCTYPE html>
|
|
52
|
+
<html lang="en">
|
|
53
|
+
<head>
|
|
54
|
+
<meta charset="UTF-8">
|
|
55
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
56
|
+
<title>TUI Terminal</title>
|
|
57
|
+
<style>#{css}</style>
|
|
58
|
+
</head>
|
|
59
|
+
<body>#{body}</body>
|
|
60
|
+
</html>
|
|
61
|
+
HTML
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Write HTML to a file
|
|
65
|
+
def render(output_path)
|
|
66
|
+
File.write(output_path, to_html)
|
|
67
|
+
output_path
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def render_css
|
|
73
|
+
<<~CSS
|
|
74
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
75
|
+
body {
|
|
76
|
+
background: #111;
|
|
77
|
+
color: #{css_color(DEFAULT_FG)};
|
|
78
|
+
font-family: "SF Mono", "Fira Code", "Cascadia Code", "JetBrains Mono", "DejaVu Sans Mono", "Menlo", "Monaco", "Courier New", monospace;
|
|
79
|
+
font-size: 14px;
|
|
80
|
+
line-height: 1.3;
|
|
81
|
+
padding: 16px;
|
|
82
|
+
}
|
|
83
|
+
.term {
|
|
84
|
+
display: inline-block;
|
|
85
|
+
background: #{css_color(DEFAULT_BG)};
|
|
86
|
+
border: 1px solid #333;
|
|
87
|
+
border-radius: 4px;
|
|
88
|
+
padding: 8px;
|
|
89
|
+
}
|
|
90
|
+
.line {
|
|
91
|
+
white-space: pre;
|
|
92
|
+
line-height: 1.3;
|
|
93
|
+
min-height: 1.3em;
|
|
94
|
+
}
|
|
95
|
+
.cursor-cell {
|
|
96
|
+
outline: 2px solid #ff0;
|
|
97
|
+
outline-offset: -1px;
|
|
98
|
+
z-index: 1;
|
|
99
|
+
position: relative;
|
|
100
|
+
}
|
|
101
|
+
CSS
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def render_body
|
|
105
|
+
lines = @grid.map.with_index do |row, ri|
|
|
106
|
+
line_html = if row.nil? || row.empty?
|
|
107
|
+
'<span class="line"></span>'
|
|
108
|
+
else
|
|
109
|
+
runs = build_runs(row, ri)
|
|
110
|
+
spans = runs.map do |run|
|
|
111
|
+
render_run(run)
|
|
112
|
+
end
|
|
113
|
+
%(<span class="line">#{spans.join}</span>)
|
|
114
|
+
end
|
|
115
|
+
line_html
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
%(<pre class="term">\n#{lines.join("\n")}\n</pre>)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def build_runs(row, ri)
|
|
122
|
+
runs = []
|
|
123
|
+
current_run = nil
|
|
124
|
+
|
|
125
|
+
row.each_with_index do |cell, ci|
|
|
126
|
+
char = (cell[:char] || cell["char"] || " ")
|
|
127
|
+
fg = cell[:fg] || cell["fg"] || "default"
|
|
128
|
+
bg = cell[:bg] || cell["bg"] || "default"
|
|
129
|
+
bold = cell[:bold] || cell["bold"] || false
|
|
130
|
+
italic = cell[:italic] || cell["italic"] || false
|
|
131
|
+
underline = cell[:underline] || cell["underline"] || false
|
|
132
|
+
|
|
133
|
+
style_key = [fg, bg, bold, italic, underline]
|
|
134
|
+
|
|
135
|
+
if current_run && current_run[:key] == style_key
|
|
136
|
+
current_run[:chars] << char
|
|
137
|
+
else
|
|
138
|
+
current_run = {
|
|
139
|
+
key: style_key,
|
|
140
|
+
chars: [char],
|
|
141
|
+
style: cell_style(fg, bg, bold, italic, underline),
|
|
142
|
+
has_cursor: is_cursor?(ri, ci)
|
|
143
|
+
}
|
|
144
|
+
runs << current_run
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
runs
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def cell_style(fg, bg, bold, italic, underline)
|
|
152
|
+
parts = []
|
|
153
|
+
parts << "color:#{css_color(resolve_color(fg, DEFAULT_FG))}"
|
|
154
|
+
parts << "background-color:#{css_color(resolve_color(bg, DEFAULT_BG))}" unless bg == "default"
|
|
155
|
+
parts << "font-weight:bold" if bold
|
|
156
|
+
parts << "font-style:italic" if italic
|
|
157
|
+
parts << "text-decoration:underline" if underline
|
|
158
|
+
parts.join(";")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def render_run(run)
|
|
162
|
+
chars = run[:chars].map { |c| escape_html(c) }.join
|
|
163
|
+
return chars if run[:style].empty? && !run[:has_cursor]
|
|
164
|
+
|
|
165
|
+
classes = []
|
|
166
|
+
classes << "cursor-cell" if run[:has_cursor]
|
|
167
|
+
cls = classes.empty? ? "" : %( class="#{classes.join(" ")}")
|
|
168
|
+
style = run[:style].empty? ? "" : %( style="#{run[:style]}")
|
|
169
|
+
%(<span#{cls}#{style}>#{chars}</span>)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def is_cursor?(ri, ci)
|
|
173
|
+
@cursor[:row] == ri && @cursor[:col] == ci
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def resolve_color(name, fallback)
|
|
177
|
+
case name
|
|
178
|
+
when "default"
|
|
179
|
+
fallback
|
|
180
|
+
when /^#([0-9a-fA-F]{6})$/
|
|
181
|
+
[$1[0..1].to_i(16), $1[2..3].to_i(16), $1[4..5].to_i(16)]
|
|
182
|
+
when /\Acolor(\d+)\z/
|
|
183
|
+
xterm_256($1.to_i)
|
|
184
|
+
when /\Abright_(.+)\z/
|
|
185
|
+
ANSI_RGB[name] || fallback
|
|
186
|
+
else
|
|
187
|
+
ANSI_RGB[name] || fallback
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def xterm_256(index)
|
|
192
|
+
if index < 16
|
|
193
|
+
name = ANSI_INDEX[index]
|
|
194
|
+
ANSI_RGB[name] || DEFAULT_FG
|
|
195
|
+
elsif index < 232
|
|
196
|
+
r = CUBE[((index - 16) / 36) % 6]
|
|
197
|
+
g = CUBE[((index - 16) / 6) % 6]
|
|
198
|
+
b = CUBE[(index - 16) % 6]
|
|
199
|
+
[r, g, b]
|
|
200
|
+
else
|
|
201
|
+
v = 8 + (index - 232) * 10
|
|
202
|
+
[v, v, v]
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def css_color(rgb)
|
|
207
|
+
format("#%02x%02x%02x", *rgb)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def escape_html(char)
|
|
211
|
+
case char
|
|
212
|
+
when "&" then "&"
|
|
213
|
+
when "<" then "<"
|
|
214
|
+
when ">" then ">"
|
|
215
|
+
when '"' then """
|
|
216
|
+
else char
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def _dig(hash, *keys)
|
|
221
|
+
keys.each do |k|
|
|
222
|
+
return nil unless hash
|
|
223
|
+
hash = hash[k] || hash[k.to_s]
|
|
224
|
+
end
|
|
225
|
+
hash
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/expectations"
|
|
4
|
+
|
|
5
|
+
# RSpec matchers for TUITD::State objects.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# require "tui_td/matchers"
|
|
9
|
+
#
|
|
10
|
+
# state = TUITD::State.new(driver.state_data)
|
|
11
|
+
# expect(state).to have_text("Welcome")
|
|
12
|
+
# expect(state).to have_fg("cyan").at(0, 0)
|
|
13
|
+
#
|
|
14
|
+
module TUITD
|
|
15
|
+
module Matchers
|
|
16
|
+
RSpec::Matchers.define :have_text do |expected|
|
|
17
|
+
match do |state|
|
|
18
|
+
state.find_text(expected).any?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
description { "have text #{expected.inspect}" }
|
|
22
|
+
failure_message { |state| "expected terminal to contain #{expected.inspect}" }
|
|
23
|
+
failure_message_when_negated { |state| "expected terminal NOT to contain #{expected.inspect}" }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
RSpec::Matchers.define :have_fg do |expected|
|
|
27
|
+
chain(:at) { |row, col| @row, @col = row, col }
|
|
28
|
+
|
|
29
|
+
match do |state|
|
|
30
|
+
@actual = state.foreground_at(@row, @col)
|
|
31
|
+
@actual == expected
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
description { "have foreground #{expected.inspect} at [#{@row},#{@col}]" }
|
|
35
|
+
failure_message do |state|
|
|
36
|
+
"expected FG at [#{@row},#{@col}] to be #{expected.inspect}, but was #{@actual.inspect}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
RSpec::Matchers.define :have_bg do |expected|
|
|
41
|
+
chain(:at) { |row, col| @row, @col = row, col }
|
|
42
|
+
|
|
43
|
+
match do |state|
|
|
44
|
+
@actual = state.background_at(@row, @col)
|
|
45
|
+
@actual == expected
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
description { "have background #{expected.inspect} at [#{@row},#{@col}]" }
|
|
49
|
+
failure_message do |state|
|
|
50
|
+
"expected BG at [#{@row},#{@col}] to be #{expected.inspect}, but was #{@actual.inspect}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
RSpec::Matchers.define :have_style do
|
|
55
|
+
chain(:at) { |row, col| @row, @col = row, col }
|
|
56
|
+
chain(:with) { |expected| @expected = expected }
|
|
57
|
+
|
|
58
|
+
match do |state|
|
|
59
|
+
@actual = state.style_at(@row, @col)
|
|
60
|
+
@expected ||= {}
|
|
61
|
+
@expected.all? { |k, v| @actual[k] == v }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
description do
|
|
65
|
+
"have style #{@expected.inspect} at [#{@row},#{@col}]"
|
|
66
|
+
end
|
|
67
|
+
failure_message do |state|
|
|
68
|
+
"expected style at [#{@row},#{@col}] to be #{@expected.inspect}, but was #{@actual.inspect}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|