modsvaskr 0.0.3 → 0.1.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0403336e82766fdd4478e36f75799855591bcf6d74b275c05922b0e9aa1076cd
4
- data.tar.gz: 0d98e88ef9dd3a59c256ac728c0f1d710e29e345403d2bc7a93f405a7248c1bd
3
+ metadata.gz: 0c7e455fc3db208e896aa655d1dba0fd91f948cf2dfd1acb5787d2d723ab9e0a
4
+ data.tar.gz: 012ee74df256dc2f3b3b2b1d33677c2b926b7a58b73f2398d05073e68a197394
5
5
  SHA512:
6
- metadata.gz: 44e815a02fb056f33c9565b86fa19b8d8a13876bddbfa130890347e3c35e60964f0ec78df0dbe1a63a880cd3dfd35c36d5db00dfc238e659e40875413e73f78c
7
- data.tar.gz: 6dc7d918edce6f789803adbda62fca560e9172af4face0d75a9dbd49b5befb0f89817c43bde6f3321a8dc78346a8912552cc0cf2bf1c143f583c3d951cc1cd22
6
+ metadata.gz: ab3dac575ad2d9266c6d3167d76c586cae9e5fe7b5169166117ffd09adad1f4c990e20d37f66a3d369fb5694dbe4bd241122ccee33f9008820599bedd74a3459
7
+ data.tar.gz: 4c5801dc0f8e71a3ebe33be2d42338f1ac73a2afae775d70a0374a212c7c14864027983f95f8267f24ddad22cce4efd5c3f8a15ab3a094b22c080c4cafea161e
@@ -12,7 +12,19 @@ module Modsvaskr
12
12
  # Parameters::
13
13
  # * *file* (String): File containing configuration
14
14
  def initialize(file)
15
- @config = YAML.load(File.read(file))
15
+ @config = YAML.load(File.read(file)) || {}
16
+ # Parse all game types plugins
17
+ # Hash<Symbol, Class>
18
+ @game_types = Hash[
19
+ Dir.glob("#{__dir__}/games/*.rb").map do |game_type_file|
20
+ require game_type_file
21
+ base_name = File.basename(game_type_file, '.rb')
22
+ [
23
+ base_name.to_sym,
24
+ Games.const_get(base_name.split('_').collect(&:capitalize).join.to_sym)
25
+ ]
26
+ end
27
+ ]
16
28
  end
17
29
 
18
30
  # Get the games list
@@ -21,9 +33,10 @@ module Modsvaskr
21
33
  # * Array<Game>: List of games
22
34
  def games
23
35
  unless defined?(@games)
24
- @games = @config['games'].map do |game_info|
25
- require "#{__dir__}/games/#{game_info['type']}.rb"
26
- Games.const_get(game_info['type'].to_s.split('_').collect(&:capitalize).join.to_sym).new(self, game_info)
36
+ @games = (@config['games'] || []).map do |game_info|
37
+ game_type = game_info['type'].to_sym
38
+ raise "Unknown game type: #{game_type}. Available ones are #{@game_types.keys.join(', ')}" unless @game_types.key?(game_type)
39
+ @game_types[game_type].new(self, game_info)
27
40
  end
28
41
  end
29
42
  @games
@@ -37,6 +50,30 @@ module Modsvaskr
37
50
  @config['xedit']
38
51
  end
39
52
 
53
+ # Return the 7-Zip path
54
+ #
55
+ # Result::
56
+ # * String: The 7-Zip path
57
+ def seven_zip_path
58
+ @config['7zip']
59
+ end
60
+
61
+ # Return the automated keys to apply
62
+ #
63
+ # Result::
64
+ # * Array<String>: The list of automated keys
65
+ def auto_keys
66
+ @config['auto_keys'] || []
67
+ end
68
+
69
+ # Return the no_prompt flag
70
+ #
71
+ # Result::
72
+ # * Boolean: no_prompt flag
73
+ def no_prompt
74
+ @config['no_prompt'] || false
75
+ end
76
+
40
77
  end
41
78
 
42
79
  end
@@ -16,10 +16,22 @@ module Modsvaskr
16
16
  # * *game_info* (Hash<String,Object>): Game info:
17
17
  # * *name* (String): Game name
18
18
  # * *path* (String): Game installation dir
19
+ # * *launch_exe* (String): Executable to be launched
20
+ # * *min_launch_time_secs* (Integer): Minimum expected lauch time for the game, in seconds [default: 10]
21
+ # * *tests_poll_secs* (Integer): Interval in seconds to be respected between 2 test statuses polling [default: 5]
22
+ # * *timeout_frozen_tests_secs* (Integer): Timeout in seconds of a frozen game [default: 300]
23
+ # * *timeout_interrupt_tests_secs* (Integer): Timeout in seconds for the player to interrupt a tests session before restarting the game [default: 10]
19
24
  def initialize(config, game_info)
20
25
  @config = config
21
- @game_info = game_info
26
+ # Set default values here
27
+ @game_info = {
28
+ 'min_launch_time_secs' => 10,
29
+ 'tests_poll_secs' => 5,
30
+ 'timeout_frozen_tests_secs' => 300,
31
+ 'timeout_interrupt_tests_secs' => 10
32
+ }.merge(game_info)
22
33
  @name = name
34
+ @pid = nil
23
35
  init if self.respond_to?(:init)
24
36
  end
25
37
 
@@ -47,6 +59,30 @@ module Modsvaskr
47
59
  @game_info['launch_exe']
48
60
  end
49
61
 
62
+ # Return the tests polling interval
63
+ #
64
+ # Result::
65
+ # * Integer: Tests polling interval
66
+ def tests_poll_secs
67
+ @game_info['tests_poll_secs']
68
+ end
69
+
70
+ # Return the timeout to detect a frozen game
71
+ #
72
+ # Result::
73
+ # * Integer: Timeout to detect a frozen game
74
+ def timeout_frozen_tests_secs
75
+ @game_info['timeout_frozen_tests_secs']
76
+ end
77
+
78
+ # Return the timeout before restarting a game tests session
79
+ #
80
+ # Result::
81
+ # * Integer: Timeout before restarting a game tests session
82
+ def timeout_interrupt_tests_secs
83
+ @game_info['timeout_interrupt_tests_secs']
84
+ end
85
+
50
86
  # Return an xEdit instance for this game
51
87
  #
52
88
  # Result::
@@ -56,6 +92,88 @@ module Modsvaskr
56
92
  @xedit
57
93
  end
58
94
 
95
+ # Launch the game, and wait for launch to be successful
96
+ #
97
+ # Parameters::
98
+ # * *autoload* (Boolean or String): If false, then launch the game using the normal launcher. If String, then use AutoLoad to load a given saved file (or empty to continue latest save) [default: false].
99
+ def launch(autoload: false)
100
+ # Launch the game
101
+ @idx_launch = 0 unless defined?(@idx_launch)
102
+ if autoload
103
+ log "[ Game #{name} ] - Launch game (##{@idx_launch}) using AutoLoad #{autoload}..."
104
+ autoload_file = "#{path}/Data/AutoLoad.cmd"
105
+ if File.exist?(autoload_file)
106
+ run_cmd({
107
+ dir: path,
108
+ exe: 'Data\AutoLoad.cmd',
109
+ args: [autoload]
110
+ })
111
+ else
112
+ log "[ Game #{name} ] - Missing file #{autoload_file}. Can't use AutoLoad to load game automatically. Please install the AutoLoad mod."
113
+ end
114
+ else
115
+ log "[ Game #{name} ] - Launch game (##{@idx_launch}) using configured launcher (#{launch_exe})..."
116
+ run_cmd({
117
+ dir: path,
118
+ exe: launch_exe
119
+ })
120
+ end
121
+ @idx_launch += 1
122
+ # The game launches asynchronously, so just wait a little bit and check for the process existence
123
+ sleep @game_info['min_launch_time_secs']
124
+ tasklist_stdout = nil
125
+ loop do
126
+ tasklist_stdout = `tasklist | find "#{running_exe}"`.strip
127
+ break unless tasklist_stdout.empty?
128
+ log "[ Game #{name} ] - #{running_exe} is not running. Wait for its startup..."
129
+ sleep 1
130
+ end
131
+ @pid = Integer(tasklist_stdout.split(' ')[1])
132
+ log "[ Game #{name} ] - #{running_exe} has started with PID #{@pid}"
133
+ end
134
+
135
+ # Is the game currently running?
136
+ #
137
+ # Result::
138
+ # * Boolean: Is the game currently running?
139
+ def running?
140
+ if @pid
141
+ running = true
142
+ begin
143
+ # Process.kill does not work when the game has crashed (the process is still detected as zombie)
144
+ # running = Process.kill(0, @pid) == 1
145
+ tasklist_stdout = `tasklist | find "#{running_exe}"`.strip
146
+ running = !tasklist_stdout.empty?
147
+ # log "[ Game #{name} ] - Tasklist returned no #{running_exe}:\n#{tasklist_stdout}" unless running
148
+ rescue Errno::ESRCH
149
+ log "[ Game #{name} ] - Got error while waiting for #{running_exe} PID #{@pid}: #{$!}"
150
+ running = false
151
+ end
152
+ @pid = nil unless running
153
+ running
154
+ else
155
+ false
156
+ end
157
+ end
158
+
159
+ # Kill the game, and wait till it is killed
160
+ def kill
161
+ if @pid
162
+ first_time = true
163
+ while @pid do
164
+ system "taskkill #{first_time ? '' : '/F '}/pid #{@pid}"
165
+ first_time = false
166
+ sleep 1
167
+ if running?
168
+ log "[ Game #{name} ] - #{running_exe} is still running (PID #{@pid}). Wait for its kill..."
169
+ sleep 5
170
+ end
171
+ end
172
+ else
173
+ log "[ Game #{name} ] - Game not started, so nothing to kill."
174
+ end
175
+ end
176
+
59
177
  end
60
178
 
61
179
  end
@@ -9,11 +9,6 @@ module Modsvaskr
9
9
  # Handle a Skyrim installation
10
10
  class SkyrimSe < Game
11
11
 
12
- SEVEN_ZIP_CMD = {
13
- dir: 'C:\Program Files\7-Zip',
14
- exe: '7z.exe'
15
- }
16
-
17
12
  # Initialize the game
18
13
  # [API] - This method is optional
19
14
  def init
@@ -28,8 +23,8 @@ module Modsvaskr
28
23
  def complete_game_menu(menu)
29
24
  menu.item 'Install SKSE64' do
30
25
  install_skse64
31
- puts 'Press Enter to continue...'
32
- $stdin.gets
26
+ out 'Press Enter to continue...'
27
+ wait_for_user_enter
33
28
  end
34
29
  end
35
30
 
@@ -60,7 +55,7 @@ module Modsvaskr
60
55
 
61
56
  # Install SKSE64 corresponding to our game
62
57
  def install_skse64
63
- doc = Nokogiri::HTML(open('https://skse.silverlock.org/'))
58
+ doc = Nokogiri::HTML(URI.open('https://skse.silverlock.org/'))
64
59
  p_element = doc.css('p').find { |el| el.text.strip =~ /^Current SE build .+: 7z archive$/ }
65
60
  if p_element.nil?
66
61
  log '!!! Can\'t get SKSE64 from https://skse.silverlock.org/. It looks like the page structure has changed. Please update the code or install it manually.'
@@ -69,14 +64,20 @@ module Modsvaskr
69
64
  path = "#{@tmp_dir}/skse64.7z"
70
65
  FileUtils.mkdir_p File.dirname(path)
71
66
  log "Download from #{url} => #{path}..."
72
- open(url, 'rb') do |web_io|
67
+ URI.open(url, 'rb') do |web_io|
73
68
  File.write(path, web_io.read, mode: 'wb')
74
69
  end
75
70
  skse64_tmp_dir = "#{@tmp_dir}/skse64"
76
71
  log "Unzip into #{skse64_tmp_dir}..."
77
72
  FileUtils.rm_rf skse64_tmp_dir
78
73
  FileUtils.mkdir_p skse64_tmp_dir
79
- run_cmd SEVEN_ZIP_CMD, args: ['x', "\"#{path}\"", "-o\"#{skse64_tmp_dir}\"", '-r']
74
+ run_cmd(
75
+ {
76
+ dir: @config.seven_zip_path,
77
+ exe: '7z.exe'
78
+ },
79
+ args: ['x', "\"#{path}\"", "-o\"#{skse64_tmp_dir}\"", '-r']
80
+ )
80
81
  skse64_subdir = Dir.glob("#{skse64_tmp_dir}/*").first
81
82
  log "Move files from #{skse64_subdir} to #{self.path}..."
82
83
  FileUtils.cp_r "#{skse64_subdir}/.", self.path, remove_destination: true
@@ -0,0 +1,345 @@
1
+ require 'base64'
2
+ require 'elder_scrolls_plugin'
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'time'
6
+ require 'modsvaskr/tests_suite'
7
+
8
+ module Modsvaskr
9
+
10
+ # Class getting a simple API to handle tests that are run in-game
11
+ class InGameTestsRunner
12
+
13
+ include Logger
14
+
15
+ # Constructor.
16
+ # Default values are for a standard Skyrim SE installation.
17
+ #
18
+ # Parameters::
19
+ # * *config* (Config): Main configuration
20
+ # * *game* (Game): Game for which we run tests
21
+ def initialize(config, game)
22
+ @config = config
23
+ @game = game
24
+ auto_test_esp = "#{@game.path}/Data/AutoTest.esp"
25
+ # Ordered list of available in-game test suites
26
+ # Array<Symbol>
27
+ @available_tests_suites =
28
+ if File.exist?(auto_test_esp)
29
+ Base64.decode64(
30
+ ElderScrollsPlugin.new(auto_test_esp).
31
+ to_json[:sub_chunks].
32
+ find { |chunk| chunk[:decoded_header][:label] == 'QUST' }[:sub_chunks].
33
+ find do |chunk|
34
+ chunk[:sub_chunks].any? { |sub_chunk| sub_chunk[:name] == 'EDID' && sub_chunk[:data] =~ /AutoTest_ScriptsQuest/ }
35
+ end[:sub_chunks].
36
+ find { |chunk| chunk[:name] == 'VMAD' }[:data]
37
+ ).scan(/AutoTest_Suite_(\w+)/).flatten.map { |tests_suite| tests_suite.downcase.to_sym }
38
+ else
39
+ log "[ In-game testing #{@game.name} ] - Missing file #{auto_test_esp}. In-game tests will be disabled. Please install the AutoTest mod."
40
+ []
41
+ end
42
+ log "[ In-game testing #{@game.name} ] - #{@available_tests_suites.size} available in-game tests suites: #{@available_tests_suites.join(', ')}"
43
+ end
44
+
45
+ # Run in-game tests in a loop until they are all tested
46
+ #
47
+ # Parameters::
48
+ # * *selected_tests* (Hash<Symbol, Array<String> >): Ordered list of in-game tests to be run, per in-game tests suite
49
+ # * Proc: Code called when a in-game test status has changed
50
+ # * Parameters::
51
+ # * *in_game_tests_suite* (Symbol): The in-game tests suite for which test statuses have changed
52
+ # * *in_game_tests_statuses* (Hash<String,String>): Tests statuses, per test name
53
+ def run(selected_tests)
54
+ unknown_tests_suites = selected_tests.keys - @available_tests_suites
55
+ log "[ In-game testing #{@game.name} ] - !!! The following in-game tests suites are not supported: #{unknown_tests_suites.join(', ')}" unless unknown_tests_suites.empty?
56
+ tests_to_run = selected_tests.select { |tests_suite, _tests| !unknown_tests_suites.include?(tests_suite) }
57
+ unless tests_to_run.empty?
58
+ FileUtils.mkdir_p "#{@game.path}/Data/SKSE/Plugins/StorageUtilData"
59
+ tests_to_run.each do |tests_suite, tests|
60
+ # Write the JSON file that contains the list of tests to run
61
+ File.write(
62
+ "#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_#{tests_suite}_Run.json",
63
+ JSON.pretty_generate(
64
+ 'stringList' => {
65
+ 'tests_to_run' => tests
66
+ }
67
+ )
68
+ )
69
+ # Clear the AutoTest test statuses that we are going to run
70
+ statuses_file = "#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_#{tests_suite}_Statuses.json"
71
+ if File.exist?(statuses_file)
72
+ File.write(
73
+ statuses_file,
74
+ JSON.pretty_generate('string' => JSON.parse(File.read(statuses_file))['string'].delete_if { |test_name, _test_status| tests.include?(test_name) })
75
+ )
76
+ end
77
+ end
78
+ auto_test_config_file = "#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_Config.json"
79
+ # Write the JSON file that contains the configuration of the AutoTest tests runner
80
+ File.write(
81
+ auto_test_config_file,
82
+ JSON.pretty_generate(
83
+ 'string' => {
84
+ 'on_start' => 'run',
85
+ 'on_stop' => 'exit'
86
+ }
87
+ )
88
+ )
89
+ out ''
90
+ out '=========================================='
91
+ out '= In-game tests are about to be launched ='
92
+ out '=========================================='
93
+ out ''
94
+ out 'Here is what you need to do once the game will be launched (don\'t launch it by yourself, the test framework will launch it for you):'
95
+ out '* Load the game save you want to test (or start a new game).'
96
+ out ''
97
+ out 'This will execute all in-game tests automatically.'
98
+ out ''
99
+ out 'It is possible that the game crashes during tests:'
100
+ out '* That\'s a normal situation, as tests don\'t mimick a realistic gaming experience, and the Bethesda engine is not meant to be stressed like that.'
101
+ out '* In case of game crash (CTD), the Modsvaskr test framework will relaunch it automatically and resume testing from when it crashed.'
102
+ out '* In case of repeated CTD on the same test, the Modsvaskr test framework will detect it and skip the crashing test automatically.'
103
+ out '* In case of a game freeze without CTD, the Modsvaskr test framework will detect it after a few minutes and automatically kill the game before re-launching it to resume testing.'
104
+ out ''
105
+ out 'If you want to interrupt in-game testing: invoke the console with ~ key and type stop_tests followed by Enter.'
106
+ out ''
107
+ out 'Press enter to start in-game testing (this will lauch your game automatically)...'
108
+ wait_for_user_enter
109
+ last_time_tests_changed = nil
110
+ with_auto_test_monitoring(
111
+ on_auto_test_statuses_diffs: proc do |in_game_tests_suite, in_game_tests_statuses|
112
+ yield in_game_tests_suite, in_game_tests_statuses
113
+ last_time_tests_changed = Time.now
114
+ end
115
+ ) do
116
+ # Loop on (re-)launching the game when we still have tests to perform
117
+ idx_launch = 0
118
+ loop do
119
+ # Check which test is supposed to run first, as it will help in knowing if it fails or not.
120
+ first_tests_suite_to_run = nil
121
+ first_test_to_run = nil
122
+ current_tests_statuses = check_auto_test_statuses
123
+ @available_tests_suites.each do |tests_suite|
124
+ if tests_to_run.key?(tests_suite)
125
+ found_test_ok =
126
+ if current_tests_statuses.key?(tests_suite)
127
+ # Find the first test that would be run (meaning the first one having no status, or status 'started')
128
+ tests_to_run[tests_suite].find do |test_name|
129
+ found_test_name, found_test_status = current_tests_statuses[tests_suite].find { |(current_test_name, _current_test_status)| current_test_name == test_name }
130
+ found_test_name.nil? || found_test_status == 'started'
131
+ end
132
+ else
133
+ # For sure the first test of this suite will be the first one to run
134
+ tests_to_run[tests_suite].first
135
+ end
136
+ if found_test_ok
137
+ first_tests_suite_to_run = tests_suite
138
+ first_test_to_run = found_test_ok
139
+ break
140
+ end
141
+ end
142
+ end
143
+ if first_tests_suite_to_run.nil?
144
+ log "[ In-game testing #{@game.name} ] - No more test to be run."
145
+ break
146
+ else
147
+ log "[ In-game testing #{@game.name} ] - First test to run should be #{first_tests_suite_to_run} / #{first_test_to_run}."
148
+ # Launch the game to execute AutoTest
149
+ @game.launch(autoload: idx_launch == 0 ? false : 'auto_test')
150
+ idx_launch += 1
151
+ log "[ In-game testing #{@game.name} ] - Start monitoring in-game testing..."
152
+ last_time_tests_changed = Time.now
153
+ while @game.running? do
154
+ check_auto_test_statuses
155
+ # If the tests haven't changed for too long, consider the game has frozen, but not crashed. So kill it.
156
+ if Time.now - last_time_tests_changed > @game.timeout_frozen_tests_secs
157
+ log "[ In-game testing #{@game.name} ] - Last time in-game tests statuses have changed is #{last_time_tests_changed.strftime('%F %T')}. Consider the game is frozen, so kill it."
158
+ @game.kill
159
+ else
160
+ sleep @game.tests_poll_secs
161
+ end
162
+ end
163
+ last_test_statuses = check_auto_test_statuses
164
+ # Log latest statuses
165
+ log "[ In-game testing #{@game.name} ] - End monitoring in-game testing. In-game test statuses after game run:"
166
+ last_test_statuses.each do |tests_suite, statuses_for_type|
167
+ log "[ In-game testing #{@game.name} ] - [ #{tests_suite} ] - #{statuses_for_type.select { |(_name, status)| status == 'ok' }.size} / #{statuses_for_type.size}"
168
+ end
169
+ # Check for which reason the game has stopped, and eventually end the testing session.
170
+ # Careful as this JSON file can be written by Papyrus that treat strings as case insensitive.
171
+ # cf. https://github.com/xanderdunn/skaar/wiki/Common-Tasks
172
+ auto_test_config = Hash[JSON.parse(File.read(auto_test_config_file))['string'].map { |key, value| [key.downcase, value.downcase] }]
173
+ if auto_test_config.dig('stopped_by') == 'user'
174
+ log "[ In-game testing #{@game.name} ] - Tests have been stopped by user."
175
+ break
176
+ end
177
+ if auto_test_config.dig('tests_execution') == 'end'
178
+ log "[ In-game testing #{@game.name} ] - Tests have finished running."
179
+ break
180
+ end
181
+ # From here we know that the game has either crashed or has been killed.
182
+ # This is an abnormal termination of the game.
183
+ # We have to know if this is due to a specific test that fails deterministically, or if it is the engine being unstable.
184
+ # Check the status of the first test that should have been run to know about it.
185
+ first_test_status = nil
186
+ _found_test_name, first_test_status = last_test_statuses[first_tests_suite_to_run].find { |(current_test_name, _current_test_status)| current_test_name == first_test_to_run } if last_test_statuses.key?(first_tests_suite_to_run)
187
+ if first_test_status == 'ok'
188
+ # It's not necessarily deterministic.
189
+ # We just have to go on executing next tests.
190
+ log "[ In-game testing #{@game.name} ] - Tests session has finished in error, certainly due to the game's normal instability. Will resume testing."
191
+ else
192
+ # The first test doesn't pass.
193
+ # We need to mark it as failed, then remove it from the runs.
194
+ log "[ In-game testing #{@game.name} ] - First test #{first_tests_suite_to_run} / #{first_test_to_run} is in error status: #{first_test_status}. Consider it failed and skip it for next run."
195
+ # If the test was started but failed before setting its status to something else then change the test status in the JSON file directly so that AutoTest does not try to re-run it.
196
+ if first_test_status == 'started' || first_test_status == '' || first_test_status.nil?
197
+ File.write(
198
+ "#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_#{first_tests_suite_to_run}_Statuses.json",
199
+ JSON.pretty_generate(
200
+ 'string' => Hash[((last_test_statuses[first_tests_suite_to_run] || []) + [[first_test_to_run, '']]).map do |(test_name, test_status)|
201
+ [
202
+ test_name,
203
+ test_name == first_test_to_run ? 'failed_ctd' : test_status
204
+ ]
205
+ end]
206
+ )
207
+ )
208
+ # Notify the callbacks updating test statuses
209
+ check_auto_test_statuses
210
+ end
211
+ end
212
+ # We will start again. Leave some time to interrupt if we want.
213
+ unless @config.no_prompt
214
+ # First, flush stdin of any pending character
215
+ $stdin.getc while !select([$stdin], nil, nil, 2).nil?
216
+ end
217
+ out "We are going to start again in #{@game.timeout_interrupt_tests_secs} seconds. Press Enter now to interrupt it."
218
+ key_pressed =
219
+ begin
220
+ Timeout.timeout(@game.timeout_interrupt_tests_secs) { $stdin.gets }
221
+ rescue
222
+ nil
223
+ end
224
+ if key_pressed
225
+ log "[ In-game testing #{@game.name} ] - Run interrupted by user."
226
+ # TODO: Remove AutoTest start on load: it has been interrupted by the user, so we should not keep it in case the user launches the game by itself.
227
+ break
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
234
+
235
+ private
236
+
237
+ # Start an AutoTest monitoring session.
238
+ # This allows checking for test statuses differences easily.
239
+ #
240
+ # Parameters::
241
+ # * *on_auto_test_statuses_diffs* (Proc): Code called when a in-game test status has changed
242
+ # * Parameters::
243
+ # * *in_game_tests_suite* (Symbol): The in-game tests suite for which test statuses have changed
244
+ # * *in_game_tests_statuses* (Hash<String,String>): Tests statuses, per test name
245
+ # * Proc: Code called with monitoring on
246
+ def with_auto_test_monitoring(on_auto_test_statuses_diffs:)
247
+ @last_auto_test_statuses = {}
248
+ @on_auto_test_statuses_diffs = on_auto_test_statuses_diffs
249
+ yield
250
+ end
251
+
252
+ # Check AutoTest test statuses differences, and call some code in case of changes in those statuses.
253
+ # Remember the last checked statuses to only find diffs on next call.
254
+ # Prerequisites: To be called inside a with_auto_test_monitoring block
255
+ #
256
+ # Result::
257
+ # * Hash<Symbol, Array<[String, String]> >: Ordered list of AutoTest [test name, test status], per AutoTest tests suite
258
+ def check_auto_test_statuses
259
+ # Log a diff in tests
260
+ new_statuses = auto_test_statuses
261
+ diff_statuses = diff_statuses(@last_auto_test_statuses, new_statuses)
262
+ unless diff_statuses.empty?
263
+ # Tests have progressed
264
+ log "[ In-game testing #{@game.name} ] - #{diff_statuses.size} tests suites have statuses changes:"
265
+ diff_statuses.each do |tests_suite, tests_statuses|
266
+ log "[ In-game testing #{@game.name} ] * #{tests_suite}:"
267
+ tests_statuses.each do |(test_name, test_status)|
268
+ log "[ In-game testing #{@game.name} ] * #{test_name}: #{test_status}"
269
+ end
270
+ @on_auto_test_statuses_diffs.call(tests_suite, Hash[tests_statuses])
271
+ end
272
+ end
273
+ # Remember the current statuses
274
+ @last_auto_test_statuses = new_statuses
275
+ @last_auto_test_statuses
276
+ end
277
+
278
+ # Get the list of AutoTest statuses
279
+ #
280
+ # Result::
281
+ # * Hash<Symbol, Array<[String, String]> >: Ordered list of AutoTest [test name, test status], per AutoTest tests suite
282
+ def auto_test_statuses
283
+ statuses = {}
284
+ `dir "#{@game.path}/Data/SKSE/Plugins/StorageUtilData" /B`.split("\n").each do |file|
285
+ if file =~ /^AutoTest_(.+)_Statuses\.json$/
286
+ auto_test_suite = $1.downcase.to_sym
287
+ # Careful as this JSON file can be written by Papyrus that treat strings as case insensitive.
288
+ # cf. https://github.com/xanderdunn/skaar/wiki/Common-Tasks
289
+ statuses[auto_test_suite] = JSON.parse(File.read("#{@game.path}/Data/SKSE/Plugins/StorageUtilData/#{file}"))['string'].map do |test_name, test_status|
290
+ [test_name.downcase, test_status.downcase]
291
+ end
292
+ end
293
+ end
294
+ statuses
295
+ end
296
+
297
+ # Diff between test statuses.
298
+ #
299
+ # Parameters::
300
+ # * *statuses1* (Hash<Symbol, Array<[String, String]> >): Test statuses, per test name (in a sorted list), per tests suite
301
+ # * *statuses2* (Hash<Symbol, Array<[String, String]> >): Test statuses, per test name (in a sorted list), per tests suite
302
+ # Result::
303
+ # * Hash<Symbol, Array<[String, String]> >: statuses2 - statuses1
304
+ def diff_statuses(statuses1, statuses2)
305
+ statuses = {}
306
+ statuses1.each do |tests_suite, tests_info|
307
+ if statuses2.key?(tests_suite)
308
+ # Handle Hashes as it will be faster
309
+ statuses1_for_test = Hash[tests_info]
310
+ statuses2_for_test = Hash[statuses2[tests_suite]]
311
+ statuses1_for_test.each do |test_name, status1|
312
+ if statuses2_for_test.key?(test_name)
313
+ if statuses2_for_test[test_name] != status1
314
+ # Change in status
315
+ statuses[tests_suite] = [] unless statuses.key?(tests_suite)
316
+ statuses[tests_suite] << [test_name, statuses2_for_test[test_name]]
317
+ end
318
+ else
319
+ # This test has been removed
320
+ statuses[tests_suite] = [] unless statuses.key?(tests_suite)
321
+ statuses[tests_suite] << [test_name, 'deleted']
322
+ end
323
+ end
324
+ statuses2_for_test.each do |test_name, status2|
325
+ unless statuses1_for_test.key?(test_name)
326
+ # This test has been added
327
+ statuses[tests_suite] = [] unless statuses.key?(tests_suite)
328
+ statuses[tests_suite] << [test_name, status2]
329
+ end
330
+ end
331
+ else
332
+ # All test statuses have been removed
333
+ statuses[tests_suite] = tests_info.map { |(test_name, _test_status)| [test_name, 'deleted'] }
334
+ end
335
+ end
336
+ statuses2.each do |tests_suite, tests_info|
337
+ # All test statuses have been added
338
+ statuses[tests_suite] = tests_info unless statuses1.key?(tests_suite)
339
+ end
340
+ statuses
341
+ end
342
+
343
+ end
344
+
345
+ end