win_gui 0.2.1 → 0.2.3

Sign up to get free protection for your applications and to get access to all the features.
data/HISTORY CHANGED
@@ -9,3 +9,11 @@
9
9
  == 0.2.1 / 2010-05-31
10
10
 
11
11
  * Complex convenience methods (dialog
12
+
13
+ == 0.2.2 / 2010-06-03
14
+
15
+ * WinGui functions can be used as instance methods on Window (with handle implicit first arg)
16
+
17
+ == 0.2.3 / 2010-06-03
18
+
19
+ * Minor cleanup/refactoring
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.2.1
1
+ 0.2.3
data/lib/extension.rb ADDED
@@ -0,0 +1,31 @@
1
+ class String
2
+ # converts one-char string into keyboard-scan 'Virtual key' code
3
+ # TODO: only letters and numbers convertible so far, need to extend further
4
+ def to_key
5
+ unless size == 1
6
+ raise "Can't convert but a single character: #{self}"
7
+ end
8
+ ascii = upcase.unpack('C')[0]
9
+ # puts "I'm here with #{self}->#{ascii}"
10
+ case self
11
+ when 'a'..'z', '0'..'9', ' '
12
+ [ascii]
13
+ when 'A'..'Z'
14
+ [WinGui.const_get(:VK_SHIFT), ascii]
15
+ when ','
16
+ [WinGui.const_get(:VK_OEM_COMMA)]
17
+ when '.'
18
+ [WinGui.const_get(:VK_OEM_PERIOD)]
19
+ when ':'
20
+ [:VK_SHIFT, :VK_OEM_1].map {|s| WinGui.const_get s}
21
+ when "\\"
22
+ [WinGui.const_get(:VK_OEM_102)]
23
+ else
24
+ raise "Can't convert unknown character: #{self}"
25
+ end
26
+ end
27
+
28
+ def to_print
29
+ force_encoding('cp1251').encode(Encoding.default_external, :undef => :replace)
30
+ end
31
+ end
@@ -2,7 +2,7 @@ require 'win/gui'
2
2
 
3
3
  module WinGui
4
4
  include Win::Gui
5
- # extend Win::Gui
5
+ extend Win::Gui
6
6
 
7
7
  # Delay between key commands/events (in sec)
8
8
  KEY_DELAY = 0.00001
@@ -12,38 +12,42 @@ module WinGui
12
12
  CLOSE_TIMEOUT = 1
13
13
  # Default timeout for dialog operations (in sec)
14
14
  LOOKUP_TIMEOUT = 3
15
+
16
+ # Window class identifying standard modal dialog window
15
17
  DIALOG_WINDOW_CLASS = '#32770'
16
18
 
17
- # Module defines convenience methods on top of straightforward Win32 API functions.
19
+ # Module defines convenience methods on top of straightforward Win32 API functions:
18
20
 
19
21
  # Finds top-level dialog window by title and yields found dialog window to attached block.
20
22
  # We work with dialog window in a block, and then we wait for it to close before proceeding.
21
23
  # That is, unless your block returns nil, in which case dialog is ignored and method immediately returns nil
22
- # If no block is given, method just returns found dialog window or nil
23
- def dialog(title, timeout=LOOKUP_TIMEOUT)
24
- dialog = Window.top_level(class: DIALOG_WINDOW_CLASS, title: title, timeout: timeout)
25
- #set_foreground_window dialog.handle if dialog # TODO: Should be converted to d_w.s_f_g call!
24
+ # If no block is given, method just returns found dialog window (or nil if dialog is not found)
25
+ def dialog(opts={})
26
+ dialog = Window.top_level( {class: DIALOG_WINDOW_CLASS, timeout: LOOKUP_TIMEOUT}.merge opts )
27
+ dialog.set_foreground_window if dialog
26
28
  wait = block_given? ? yield(dialog) : false
27
29
  dialog.wait_for_close if dialog && wait
28
30
  dialog
29
31
  end
30
32
 
31
33
  # Emulates combinations of (any amount of) keys pressed one after another (Ctrl+Alt+P) and then released
32
- # *keys should be a sequence of a virtual-key codes. The codes must be a value in the range 1 to 254.
34
+ # *keys should be a sequence of a virtual-key codes. These codes must be a value in the range 1 to 254.
33
35
  # For a complete list, see msdn:Virtual Key Codes.
36
+ # If alphanumerical char is given instead of virtual key code, only lowercase letters result (no VK_SHIFT!).
34
37
  def keystroke(*keys)
35
38
  return if keys.empty?
36
- keybd_event keys.first, 0, KEYEVENTF_KEYDOWN, 0
39
+ key = String === keys.first ? keys.first.upcase.ord : keys.first.to_i
40
+ keybd_event key, 0, KEYEVENTF_KEYDOWN, 0
37
41
  sleep KEY_DELAY
38
42
  keystroke *keys[1..-1]
39
43
  sleep KEY_DELAY
40
- keybd_event keys.first, 0, KEYEVENTF_KEYUP, 0
44
+ keybd_event key, 0, KEYEVENTF_KEYUP, 0
41
45
  end
42
46
 
43
- # types text message into window holding the focus
47
+ # types text message into a window currently holding the focus
44
48
  def type_in(message)
45
49
  message.scan(/./m) do |char|
46
- keystroke(*char.to_vkeys)
50
+ keystroke(*char.to_key)
47
51
  end
48
52
  end
49
53
 
@@ -4,8 +4,8 @@ module WinGui
4
4
  class Window
5
5
  # Make convenience methods from both WinGui and Win::Gui available as both class and instance methods
6
6
  # Looks a bit circular though...
7
- include WinGui
8
- extend WinGui
7
+ # include WinGui
8
+ # extend WinGui
9
9
 
10
10
  def initialize(handle)
11
11
  @handle = handle
@@ -22,46 +22,33 @@ module WinGui
22
22
  def self.top_level(opts={})
23
23
  window_title = opts[:title]
24
24
  window_class = opts[:class]
25
- timeout = opts[:timeout] # || LOOKUP_TIMEOUT
25
+ timeout = opts[:timeout] # || LOOKUP_TIMEOUT ? # no timeout by default
26
26
 
27
27
  if timeout
28
28
  begin
29
29
  timeout(timeout) do
30
- sleep SLEEP_DELAY while (@handle = find_window window_class, window_title) == nil
30
+ sleep SLEEP_DELAY while (@handle = WinGui.find_window window_class, window_title) == nil
31
31
  end
32
32
  rescue TimeoutError
33
33
  nil
34
34
  end
35
35
  else
36
- @handle = find_window window_class, window_title
36
+ @handle = WinGui.find_window window_class, window_title
37
37
  end
38
38
  Window.new(@handle) if @handle
39
39
  end
40
40
 
41
- # def self.top_level(title, seconds=10, wnd_class = nil)
42
- # @handle = timeout(seconds) do
43
- # loop do
44
- # h = find_window wnd_class, title
45
- # break h if h > 0
46
- # sleep 0.3
47
- # end
48
- # end
49
- #
50
- # Gui.new @handle
51
- # end
52
- # end
53
-
54
41
  # find child window (control) by title, window class, or control ID:
55
42
  def child(id)
56
43
  result = case id
57
44
  when String
58
- by_title = find_window_ex @handle, 0, nil, id.gsub('_', '&' )
59
- by_class = find_window_ex @handle, 0, id, nil
45
+ by_title = find_window_ex 0, nil, id.gsub('_', '&' )
46
+ by_class = find_window_ex 0, id, nil
60
47
  by_title ? by_title : by_class
61
48
  when Fixnum
62
- get_dlg_item @handle, id
49
+ get_dlg_item id
63
50
  when nil
64
- find_window_ex @handle, 0, nil, nil
51
+ find_window_ex 0, nil, nil
65
52
  else
66
53
  nil
67
54
  end
@@ -69,40 +56,51 @@ module WinGui
69
56
  Window.new result
70
57
  end
71
58
 
72
- # returns array of Windows that are descendants (not only DIRECTchildren) of a given Window
59
+ # returns array of Windows that are descendants (not only DIRECT children) of a given Window
73
60
  def children
74
- enum_child_windows(@handle).map{|child_handle| Window.new child_handle}
61
+ enum_child_windows.map{|child_handle| Window.new child_handle}
75
62
  end
76
63
 
77
64
  # emulate click of the control identified by id
78
65
  def click(id)
79
- left, top, right, bottom = get_window_rect child(id).handle
66
+ left, top, right, bottom = child(id).get_window_rect
80
67
  center = [(left + right) / 2, (top + bottom) / 2]
81
- set_cursor_pos *center
82
- mouse_event MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0
83
- mouse_event MOUSEEVENTF_LEFTUP, 0, 0, 0, 0
84
- end
85
-
86
- def close
87
- post_message @handle, WM_SYSCOMMAND, SC_CLOSE, nil
68
+ WinGui.set_cursor_pos *center
69
+ WinGui.mouse_event WinGui::MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0
70
+ WinGui.mouse_event WinGui::MOUSEEVENTF_LEFTUP, 0, 0, 0, 0
88
71
  end
89
72
 
90
- def wait_for_close
91
- timeout(CLOSE_TIMEOUT) do
92
- sleep SLEEP_DELAY while window_visible?(@handle)
73
+ def wait_for_close(timeout =CLOSE_TIMEOUT )
74
+ timeout(timeout) do
75
+ sleep SLEEP_DELAY while window_visible?
93
76
  end
94
77
  end
95
78
 
96
- # Window class name property - static (not changing)
97
- def class_name
98
- @class_name ||= get_class_name @handle
79
+ # We alias convenience method shut_window (from Win::Gui::WIndow) with even more convenient
80
+ # window.close
81
+ # Please keep in mind that Win32 API has another function CloseWindow that merely MINIMIZES window.
82
+ # If you want to invoke this function, you can do it like this:
83
+ # window.close_window
84
+ def close
85
+ shut_window
99
86
  end
100
87
 
101
- # Window text/title property - dynamic (changing)
102
- def text
103
- buffer = FFI::MemoryPointer.from_string("\x0" * 2048)
104
- num_chars = send_message @handle, WM_GETTEXT, buffer.size, buffer # length?
105
- num_chars == 0 ? '' : buffer.read_string
88
+ # Since Window instances wrap actual window handles, they should support WinGui functions
89
+ # manipulating these handles. Therefore, when unsupported instance method is invoked, we check if
90
+ # WinGui responds to such method, and if yes, call it with our window handle as a first argument.
91
+ # This gives us all handle-related WinGui functions as instance methods for Window instances, like so:
92
+ # window.visible?
93
+ # This API is much more Ruby-like compared to:
94
+ # visible?(window.handle)
95
+ # Of course, if we unvoke WinGui function that DOESN'T accept handle as a first arg this way, we are screwed.
96
+ # Call such functions only like this:
97
+ # WinGui.function(*args)
98
+ def method_missing(name, *args, &block)
99
+ if WinGui.respond_to? name
100
+ WinGui.send(name, @handle, *args, &block)
101
+ else
102
+ super
103
+ end
106
104
  end
107
105
  end
108
106
  end
data/lib/win_gui.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require 'version'
2
+ require 'extension'
2
3
 
3
4
  module WinGui
4
5
 
@@ -0,0 +1,64 @@
1
+ # encoding: UTF-8
2
+ require File.join(File.dirname(__FILE__), "spec_helper" )
3
+
4
+ module WinGuiTest
5
+
6
+ describe String do
7
+ describe '#to_key' do
8
+ it 'transforms number char into [equivalent key code]' do
9
+ ('0'..'9').each {|char| char.to_key.should == char.unpack('C')}
10
+ end
11
+
12
+ it 'transforms uppercase letters into [shift, equivalent key code]' do
13
+ ('A'..'Z').each {|char| char.to_key.should == [0x10, *char.unpack('C')]}
14
+ end
15
+
16
+ it 'transforms lowercase letters into [(upcase) key code]' do
17
+ ('a'..'z').each {|char| char.to_key.should == char.upcase.unpack('C')}
18
+ end
19
+
20
+ it 'transforms space into [equivalent key code]' do
21
+ ' '.to_key.should == " ".unpack('C')
22
+ end
23
+
24
+ it 'transforms .,:\\ into [equivalent key code]' do
25
+ ','.to_key.should == [VK_OEM_COMMA]
26
+ '.'.to_key.should == [VK_OEM_PERIOD]
27
+ ':'.to_key.should == [VK_SHIFT, VK_OEM_1]
28
+ "\\".to_key.should == [VK_OEM_102]
29
+ end
30
+
31
+ it 'raises error if char is not implemented punctuation' do
32
+ ('!'..'+').each {|char| lambda {char.to_key}.should raise_error ERROR_CONVERSION }
33
+ (';'..'@').each {|char| lambda {char.to_key}.should raise_error ERROR_CONVERSION }
34
+ (']'..'`').each {|char| lambda {char.to_key}.should raise_error ERROR_CONVERSION }
35
+ ('{'..'~').each {|char| lambda {char.to_key}.should raise_error ERROR_CONVERSION }
36
+ ['-', '/', '['].each {|char| lambda {char.to_key}.should raise_error ERROR_CONVERSION }
37
+ end
38
+
39
+ it 'raises error if char is non-printable or non-ascii' do
40
+ lambda {1.chr.to_key}.should raise_error ERROR_CONVERSION
41
+ lambda {230.chr.to_key}.should raise_error ERROR_CONVERSION
42
+ end
43
+
44
+ it 'raises error if string is multi-char' do
45
+ lambda {'hello'.to_key}.should raise_error ERROR_CONVERSION
46
+ lambda {'23'.to_key}.should raise_error ERROR_CONVERSION
47
+ end
48
+ end
49
+
50
+ describe '#to_print' do
51
+ it 'converts String from (implied) WinCyrillic (CP1251) to default output encoding' do
52
+ string = "Широкая электрификация южных губерний даст мощный толчок подъёму сельского хозяйства"
53
+ win_string = string.encode('cp1251')
54
+ print_string = win_string.encode(Encoding.default_external, :undef => :replace)
55
+ win_string_thought_utf8 = win_string.force_encoding('utf-8')
56
+ win_string_thought_dos = win_string.force_encoding('cp866')
57
+
58
+ win_string_thought_utf8.to_print.should == print_string
59
+ win_string_thought_dos.to_print.should == print_string
60
+ end
61
+
62
+ end
63
+ end
64
+ end
data/spec/spec_helper.rb CHANGED
@@ -62,29 +62,28 @@ module WinGuiTest
62
62
 
63
63
  def launch_test_app
64
64
  system APP_START
65
- sleep SLEEP_DELAY until (handle = find_window(nil, WIN_TITLE))
66
- @launched_test_app = Window.new handle
65
+ @test_app = Window.top_level( title: WIN_TITLE, timeout: 10)
67
66
 
68
- def @launched_test_app.textarea #define singleton method retrieving app's text area
69
- Window.new find_window_ex(self.handle, 0, TEXTAREA_CLASS, nil)
67
+ def @test_app.textarea #define singleton method retrieving app's text area
68
+ Window.new WinGui::find_window_ex(self.handle, 0, TEXTAREA_CLASS, nil)
70
69
  end
71
70
 
72
- @launched_test_app
71
+ @test_app
73
72
  end
74
73
 
75
- def close_test_app(app = @launched_test_app)
76
- while app && app.respond_to?(:handle) && find_window(nil, WIN_TITLE)
77
- post_message(app.handle, WM_SYSCOMMAND, SC_CLOSE, nil)
78
- sleep SLEEP_DELAY
74
+ def close_test_app
75
+ while @test_app && find_window(nil, WIN_TITLE)
76
+ @test_app.close
77
+ # Dealing with closing confirmation modal dialog
78
+ keystroke("N") if Window.top_level( title: "Steganos Locknote", timeout: SLEEP_DELAY*5)
79
79
  end
80
- @launched_test_app = nil
80
+ @test_app = nil
81
81
  end
82
82
 
83
83
  # Creates test app object and yields it back to the block
84
84
  def test_app
85
- app = launch_test_app
86
-
87
- yield app
85
+ test_app = launch_test_app
86
+ yield test_app
88
87
  close_test_app
89
88
  end
90
89
 
@@ -7,18 +7,20 @@ module WinGuiTest
7
7
  after(:each) { close_test_app }
8
8
 
9
9
  describe '#dialog' do
10
- before(:each){ keystroke(VK_ALT, 'F'.ord, 'A'.ord) }
11
- after(:each) { keystroke(VK_ESCAPE) }
10
+ # Open "Save as" modal dialog
11
+ before(:each){ keystroke(VK_ALT, 'F', 'A') }
12
+ # Close modal dialog if it is opened
13
+ after(:each) { keystroke(VK_ESCAPE) if Window.top_level( title: "Save As", timeout: 0.1) }
12
14
 
13
15
  it 'returns top-level dialog window with given title if no block attached' do
14
- dialog_window = dialog(DIALOG_TITLE, 0.1)
16
+ dialog_window = dialog(title: DIALOG_TITLE, timeout: 0.1)
15
17
  dialog_window.should_not == nil
16
18
  dialog_window.should be_a Window
17
19
  dialog_window.text.should == DIALOG_TITLE
18
20
  end
19
21
 
20
22
  it 'yields found dialog window to block if block is attached' do
21
- dialog(DIALOG_TITLE, 0.1) do |dialog_window|
23
+ dialog(title: DIALOG_TITLE) do |dialog_window|
22
24
  dialog_window.should_not == nil
23
25
  dialog_window.should be_a Window
24
26
  dialog_window.text.should == DIALOG_TITLE
@@ -26,39 +28,38 @@ module WinGuiTest
26
28
  end
27
29
 
28
30
  it 'returns nil if there is no dialog with given title' do
29
- dialog(IMPOSSIBLE, 0.1).should == nil
31
+ dialog(title: IMPOSSIBLE, timeout: 0.1).should == nil
30
32
  end
31
33
 
32
34
  it 'yields nil to attached block if no dialog found' do
33
- dialog(IMPOSSIBLE, 0.1) do |dialog_window|
35
+ dialog(title: IMPOSSIBLE, timeout: 0.1) do |dialog_window|
34
36
  dialog_window.should == nil
35
37
  end
36
38
  end
37
39
 
38
- it 'considers timeout argument optional' do
39
- dialog_window = dialog(DIALOG_TITLE)
40
- dialog_window.text.should == DIALOG_TITLE
40
+ it 'considers all arguments optional' do
41
+ use { dialog_window = dialog() }
41
42
  end
42
43
  end # describe dialog
43
44
 
44
45
  describe 'convenience input methods on top of Windows API' do
45
46
  describe '#keystroke' do
46
- spec{ use{ keystroke( vkey = 30, vkey = 30) }}
47
+ spec{ use{ keystroke( vkey = 30, char = 'Z') }}
47
48
 
48
49
  it 'emulates combinations of keys pressed (Ctrl+Alt+P+M, etc)' do
49
- keystroke(VK_CONTROL, 'A'.ord)
50
+ keystroke(VK_CONTROL, 'A')
50
51
  keystroke(VK_SPACE)
51
52
  @app.textarea.text.should.should == ' '
52
- 2.times {keystroke(VK_CONTROL, 'Z'.ord)} # rolling back changes to allow window closing without dialog!
53
+ keystroke('1', '2', 'A', 'B'.ord)
54
+ @app.textarea.text.should.should == ' 12ab'
53
55
  end
54
56
  end # describe '#keystroke'
55
57
 
56
58
  describe '#type_in' do
57
59
  it 'types text message into the window holding the focus' do
58
- text = '12 34'
60
+ text = '1234 abcdefg'
59
61
  type_in(text)
60
62
  @app.textarea.text.should =~ Regexp.new(text)
61
- 5.times {keystroke(VK_CONTROL, 'Z'.ord)} # rolling back changes to allow window closing without dialog!
62
63
  end
63
64
  end # describe '#type_in'
64
65
 
@@ -14,20 +14,11 @@ module WinGuiTest
14
14
  end
15
15
 
16
16
  context 'manipulating' do
17
-
18
17
  it 'has handle property equal to underlying window handle' do
19
18
  any = Window.new any_handle
20
19
  any.handle.should == any_handle
21
20
  end
22
21
 
23
- it 'has class_name property' do
24
- @app.class_name.should == WIN_CLASS
25
- end
26
-
27
- it 'has text property equal to underlying window text(title)' do
28
- @app.text.should == WIN_TITLE
29
- end
30
-
31
22
  it 'closes when asked nicely' do
32
23
  @app.close
33
24
  sleep SLEEP_DELAY # needed to ensure window had enough time to close down
@@ -44,6 +35,29 @@ module WinGuiTest
44
35
  end
45
36
  end
46
37
 
38
+ context 'handle-related WinGui functions as instance methods' do
39
+ it 'calls all WinGui functions as instance methods (with handle as implicit first argument)' do
40
+ @app.window?.should == true
41
+ @app.visible?.should == true
42
+ @app.foreground?.should == true
43
+ @app.maximized?.should == false
44
+ @app.maximized?.should == false
45
+ @app.child?(any_handle).should == false
46
+
47
+ @app.window_rect.should be_an Array
48
+ @app.window_thread_process_id.should be_an Array
49
+ @app.enum_child_windows.should be_an Array
50
+ end
51
+
52
+ it 'has class_name and text properties (derived from WinGui function calls)' do
53
+ @app.class_name.should == WIN_CLASS
54
+ # window_text propery accessed via GetWindowText
55
+ @app.window_text.should == WIN_TITLE
56
+ # text propery accessed by sending WM_GETTEXT directly to window (convenience method in WinGui)
57
+ @app.text.should == WIN_TITLE
58
+ end
59
+ end
60
+
47
61
  describe '.top_level' do
48
62
  it 'finds top-level window by title and wraps it in a Window object' do
49
63
  win = Window.top_level( title: WIN_TITLE, timeout: 1)
@@ -68,8 +82,8 @@ module WinGuiTest
68
82
 
69
83
  it 'returns nil after timeout if top-level window with given title not found' do
70
84
  start = Time.now
71
- Window.top_level( title: IMPOSSIBLE, timeout: 1).should == nil
72
- (Time.now - start).should be_close 1, 0.02
85
+ Window.top_level( title: IMPOSSIBLE, timeout: 0.5).should == nil
86
+ (Time.now - start).should be_close 0.5, 0.02
73
87
  end
74
88
  end
75
89
 
@@ -104,13 +118,13 @@ module WinGuiTest
104
118
  describe '#children' do
105
119
  spec { use { children = @app.children }}
106
120
 
107
- it 'returns an array of Windows that are descendants (not only DIRECTchildren) of a given Window' do
121
+ it 'returns an array of Windows that are descendants (not only DIRECT children) of a given Window' do
108
122
  children = @app.children
109
123
  children.should be_a_kind_of Array
110
124
  children.should_not be_empty
111
125
  children.should have(2).elements
112
126
  children.each{|child| child?(@app.handle, child.handle).should == true }
113
- get_class_name(children.last.handle).should == TEXTAREA_CLASS
127
+ children.last.class_name.should == TEXTAREA_CLASS
114
128
  end
115
129
 
116
130
  # it 'finds child window(control) by name' do
metadata CHANGED
@@ -5,8 +5,8 @@ version: !ruby/object:Gem::Version
5
5
  segments:
6
6
  - 0
7
7
  - 2
8
- - 1
9
- version: 0.2.1
8
+ - 3
9
+ version: 0.2.3
10
10
  platform: ruby
11
11
  authors:
12
12
  - arvicco
@@ -14,7 +14,7 @@ autorequire:
14
14
  bindir: bin
15
15
  cert_chain: []
16
16
 
17
- date: 2010-05-31 00:00:00 +04:00
17
+ date: 2010-06-03 00:00:00 +04:00
18
18
  default_executable:
19
19
  dependencies:
20
20
  - !ruby/object:Gem::Dependency
@@ -68,10 +68,12 @@ extra_rdoc_files:
68
68
  - HISTORY
69
69
  - README.rdoc
70
70
  files:
71
+ - lib/extension.rb
71
72
  - lib/version.rb
72
73
  - lib/win_gui/convenience.rb
73
74
  - lib/win_gui/window.rb
74
75
  - lib/win_gui.rb
76
+ - spec/extension_spec.rb
75
77
  - spec/spec.opts
76
78
  - spec/spec_helper.rb
77
79
  - spec/win_gui/convenience_spec.rb
@@ -127,6 +129,7 @@ signing_key:
127
129
  specification_version: 3
128
130
  summary: Abstractions/wrappers around GUI-related Win32 API functions
129
131
  test_files:
132
+ - spec/extension_spec.rb
130
133
  - spec/spec.opts
131
134
  - spec/spec_helper.rb
132
135
  - spec/win_gui/convenience_spec.rb