modsvaskr 0.0.4 → 0.1.0

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: 9342c8943f1a2deb5b824f349319dae072d8bfa69cbc1e68af95fc518805f35b
4
- data.tar.gz: 7cfcdb9b25f93b670e516bf8e992fae6bd51249981df565be2024429c48a2957
3
+ metadata.gz: cf22182b9e2f38b08900947b107bac303c8d9c6eebbc7b10ce80b88a899fd4da
4
+ data.tar.gz: 58a6a9f16573766eec853391ef1aa0efd2a649a691e530a16bbc8de0268c11b9
5
5
  SHA512:
6
- metadata.gz: 104e7a71646fecf55debb31fff6e7a7498b5a2ce263c3a6e1fbafe21fed84f54c09134376bfc9534b20d52f73db262ed0a2c42a3d03a747cad1f1f36b6bd62eb
7
- data.tar.gz: 867b8bee8c3efe11380693cbfebc17c869094d344a3ba6e0aa9681807b298a00d6c5981a85fa75e4fa8b118dc4d687a4d24b8161f0ac640486eb2d9c92bff593
6
+ metadata.gz: 87849b6c50f3cc08db5d150fada9511ff412ab2458310a2f6e64c6126f7037e7e656a379c383b7598449e728be0f925bdb06df39a2e760895c4c9b3a1aca85ff
7
+ data.tar.gz: 10edfe86882d700c6a052276810b9e21aff2022a89e6b4d082ca03ab4eedb00a8ac0604e93a637492778823c7720133103c72d5adeb06d1240ada02d58a75fb5
@@ -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,335 @@
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
70
+ File.unlink("#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_#{tests_suite}_Statuses.json") if File.exist?("#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_#{tests_suite}_Statuses.json")
71
+ end
72
+ auto_test_config_file = "#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_Config.json"
73
+ # Write the JSON file that contains the configuration of the AutoTest tests runner
74
+ File.write(
75
+ auto_test_config_file,
76
+ JSON.pretty_generate(
77
+ 'string' => {
78
+ 'on_start' => 'run',
79
+ 'on_stop' => 'exit'
80
+ }
81
+ )
82
+ )
83
+ out ''
84
+ out '=========================================='
85
+ out '= In-game tests are about to be launched ='
86
+ out '=========================================='
87
+ out ''
88
+ 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):'
89
+ out '* Load the game save you want to test (or start a new game).'
90
+ out ''
91
+ out 'This will execute all in-game tests automatically.'
92
+ out ''
93
+ out 'It is possible that the game crashes during tests:'
94
+ 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.'
95
+ out '* In case of game crash (CTD), the Modsvaskr test framework will relaunch it automatically and resume testing from when it crashed.'
96
+ out '* In case of repeated CTD on the same test, the Modsvaskr test framework will detect it and skip the crashing test automatically.'
97
+ 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.'
98
+ out ''
99
+ out 'If you want to interrupt in-game testing: invoke the console with ~ key and type stop_tests followed by Enter.'
100
+ out ''
101
+ out 'Press enter to start in-game testing (this will lauch your game automatically)...'
102
+ wait_for_user_enter
103
+ last_time_tests_changed = nil
104
+ with_auto_test_monitoring(
105
+ on_auto_test_statuses_diffs: proc do |in_game_tests_suite, in_game_tests_statuses|
106
+ yield in_game_tests_suite, in_game_tests_statuses
107
+ last_time_tests_changed = Time.now
108
+ end
109
+ ) do
110
+ # Loop on (re-)launching the game when we still have tests to perform
111
+ idx_launch = 0
112
+ loop do
113
+ # Check which test is supposed to run first, as it will help in knowing if it fails or not.
114
+ first_tests_suite_to_run = nil
115
+ first_test_to_run = nil
116
+ current_tests_statuses = check_auto_test_statuses
117
+ @available_tests_suites.each do |tests_suite|
118
+ if tests_to_run.key?(tests_suite)
119
+ found_test_ok =
120
+ if current_tests_statuses.key?(tests_suite)
121
+ # Find the first test that would be run (meaning the first one having no status, or status 'started')
122
+ tests_to_run[tests_suite].find do |test_name|
123
+ found_test_name, found_test_status = current_tests_statuses[tests_suite].find { |(current_test_name, _current_test_status)| current_test_name == test_name }
124
+ found_test_name.nil? || found_test_status == 'started'
125
+ end
126
+ else
127
+ # For sure the first test of this suite will be the first one to run
128
+ tests_to_run[tests_suite].first
129
+ end
130
+ if found_test_ok
131
+ first_tests_suite_to_run = tests_suite
132
+ first_test_to_run = found_test_ok
133
+ break
134
+ end
135
+ end
136
+ end
137
+ if first_tests_suite_to_run.nil?
138
+ log "[ In-game testing #{@game.name} ] - No more test to be run."
139
+ break
140
+ else
141
+ log "[ In-game testing #{@game.name} ] - First test to run should be #{first_tests_suite_to_run} / #{first_test_to_run}."
142
+ # Launch the game to execute AutoTest
143
+ @game.launch(autoload: idx_launch == 0 ? false : 'auto_test')
144
+ idx_launch += 1
145
+ log "[ In-game testing #{@game.name} ] - Start monitoring in-game testing..."
146
+ last_time_tests_changed = Time.now
147
+ while @game.running? do
148
+ check_auto_test_statuses
149
+ # If the tests haven't changed for too long, consider the game has frozen, but not crashed. So kill it.
150
+ if Time.now - last_time_tests_changed > @game.timeout_frozen_tests_secs
151
+ 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."
152
+ @game.kill
153
+ else
154
+ sleep @game.tests_poll_secs
155
+ end
156
+ end
157
+ last_test_statuses = check_auto_test_statuses
158
+ # Log latest statuses
159
+ log "[ In-game testing #{@game.name} ] - End monitoring in-game testing. In-game test statuses after game run:"
160
+ last_test_statuses.each do |tests_suite, statuses_for_type|
161
+ log "[ In-game testing #{@game.name} ] - [ #{tests_suite} ] - #{statuses_for_type.select { |(_name, status)| status == 'ok' }.size} / #{statuses_for_type.size}"
162
+ end
163
+ # Check for which reason the game has stopped, and eventually end the testing session.
164
+ # Careful as this JSON file can be written by Papyrus that treat strings as case insensitive.
165
+ # cf. https://github.com/xanderdunn/skaar/wiki/Common-Tasks
166
+ auto_test_config = Hash[JSON.parse(File.read(auto_test_config_file))['string'].map { |key, value| [key.downcase, value.downcase] }]
167
+ if auto_test_config.dig('stopped_by') == 'user'
168
+ log "[ In-game testing #{@game.name} ] - Tests have been stopped by user."
169
+ break
170
+ end
171
+ if auto_test_config.dig('tests_execution') == 'end'
172
+ log "[ In-game testing #{@game.name} ] - Tests have finished running."
173
+ break
174
+ end
175
+ # From here we know that the game has either crashed or has been killed.
176
+ # This is an abnormal termination of the game.
177
+ # We have to know if this is due to a specific test that fails deterministically, or if it is the engine being unstable.
178
+ # Check the status of the first test that should have been run to know about it.
179
+ first_test_status = nil
180
+ _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)
181
+ if first_test_status == 'ok'
182
+ # It's not necessarily deterministic.
183
+ # We just have to go on executing next tests.
184
+ log "[ In-game testing #{@game.name} ] - Tests session has finished in error, certainly due to the game's normal instability. Will resume testing."
185
+ else
186
+ # The first test doesn't pass.
187
+ # We need to mark it as failed, then remove it from the runs.
188
+ 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."
189
+ # 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.
190
+ if first_test_status == 'started' || first_test_status == '' || first_test_status.nil?
191
+ File.write(
192
+ "#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_#{first_tests_suite_to_run}_Statuses.json",
193
+ JSON.pretty_generate(
194
+ 'string' => Hash[((last_test_statuses[first_tests_suite_to_run] || []) + [[first_test_to_run, '']]).map do |(test_name, test_status)|
195
+ [
196
+ test_name,
197
+ test_name == first_test_to_run ? 'failed_ctd' : test_status
198
+ ]
199
+ end]
200
+ )
201
+ )
202
+ # Notify the callbacks updating test statuses
203
+ check_auto_test_statuses
204
+ end
205
+ end
206
+ # We will start again. Leave some time to interrupt if we want.
207
+ out "We are going to start again in #{@game.timeout_interrupt_tests_secs} seconds. Press Enter now to interrupt it."
208
+ key_pressed =
209
+ begin
210
+ Timeout.timeout(@game.timeout_interrupt_tests_secs) { $stdin.gets }
211
+ rescue
212
+ nil
213
+ end
214
+ if key_pressed
215
+ log "[ In-game testing #{@game.name} ] - Run interrupted by user."
216
+ # 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.
217
+ break
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
224
+
225
+ private
226
+
227
+ # Start an AutoTest monitoring session.
228
+ # This allows checking for test statuses differences easily.
229
+ #
230
+ # Parameters::
231
+ # * *on_auto_test_statuses_diffs* (Proc): Code called when a in-game test status has changed
232
+ # * Parameters::
233
+ # * *in_game_tests_suite* (Symbol): The in-game tests suite for which test statuses have changed
234
+ # * *in_game_tests_statuses* (Hash<String,String>): Tests statuses, per test name
235
+ # * Proc: Code called with monitoring on
236
+ def with_auto_test_monitoring(on_auto_test_statuses_diffs:)
237
+ @last_auto_test_statuses = {}
238
+ @on_auto_test_statuses_diffs = on_auto_test_statuses_diffs
239
+ yield
240
+ end
241
+
242
+ # Check AutoTest test statuses differences, and call some code in case of changes in those statuses.
243
+ # Remember the last checked statuses to only find diffs on next call.
244
+ # Prerequisites: To be called inside a with_auto_test_monitoring block
245
+ #
246
+ # Result::
247
+ # * Hash<Symbol, Array<[String, String]> >: Ordered list of AutoTest [test name, test status], per AutoTest tests suite
248
+ def check_auto_test_statuses
249
+ # Log a diff in tests
250
+ new_statuses = auto_test_statuses
251
+ diff_statuses = diff_statuses(@last_auto_test_statuses, new_statuses)
252
+ unless diff_statuses.empty?
253
+ # Tests have progressed
254
+ log "[ In-game testing #{@game.name} ] - #{diff_statuses.size} tests suites have statuses changes:"
255
+ diff_statuses.each do |tests_suite, tests_statuses|
256
+ log "[ In-game testing #{@game.name} ] * #{tests_suite}:"
257
+ tests_statuses.each do |(test_name, test_status)|
258
+ log "[ In-game testing #{@game.name} ] * #{test_name}: #{test_status}"
259
+ end
260
+ @on_auto_test_statuses_diffs.call(tests_suite, Hash[tests_statuses])
261
+ end
262
+ end
263
+ # Remember the current statuses
264
+ @last_auto_test_statuses = new_statuses
265
+ @last_auto_test_statuses
266
+ end
267
+
268
+ # Get the list of AutoTest statuses
269
+ #
270
+ # Result::
271
+ # * Hash<Symbol, Array<[String, String]> >: Ordered list of AutoTest [test name, test status], per AutoTest tests suite
272
+ def auto_test_statuses
273
+ statuses = {}
274
+ `dir "#{@game.path}/Data/SKSE/Plugins/StorageUtilData" /B`.split("\n").each do |file|
275
+ if file =~ /^AutoTest_(.+)_Statuses\.json$/
276
+ auto_test_suite = $1.downcase.to_sym
277
+ # Careful as this JSON file can be written by Papyrus that treat strings as case insensitive.
278
+ # cf. https://github.com/xanderdunn/skaar/wiki/Common-Tasks
279
+ statuses[auto_test_suite] = JSON.parse(File.read("#{@game.path}/Data/SKSE/Plugins/StorageUtilData/#{file}"))['string'].map do |test_name, test_status|
280
+ [test_name.downcase, test_status.downcase]
281
+ end
282
+ end
283
+ end
284
+ statuses
285
+ end
286
+
287
+ # Diff between test statuses.
288
+ #
289
+ # Parameters::
290
+ # * *statuses1* (Hash<Symbol, Array<[String, String]> >): Test statuses, per test name (in a sorted list), per tests suite
291
+ # * *statuses2* (Hash<Symbol, Array<[String, String]> >): Test statuses, per test name (in a sorted list), per tests suite
292
+ # Result::
293
+ # * Hash<Symbol, Array<[String, String]> >: statuses2 - statuses1
294
+ def diff_statuses(statuses1, statuses2)
295
+ statuses = {}
296
+ statuses1.each do |tests_suite, tests_info|
297
+ if statuses2.key?(tests_suite)
298
+ # Handle Hashes as it will be faster
299
+ statuses1_for_test = Hash[tests_info]
300
+ statuses2_for_test = Hash[statuses2[tests_suite]]
301
+ statuses1_for_test.each do |test_name, status1|
302
+ if statuses2_for_test.key?(test_name)
303
+ if statuses2_for_test[test_name] != status1
304
+ # Change in status
305
+ statuses[tests_suite] = [] unless statuses.key?(tests_suite)
306
+ statuses[tests_suite] << [test_name, statuses2_for_test[test_name]]
307
+ end
308
+ else
309
+ # This test has been removed
310
+ statuses[tests_suite] = [] unless statuses.key?(tests_suite)
311
+ statuses[tests_suite] << [test_name, 'deleted']
312
+ end
313
+ end
314
+ statuses2_for_test.each do |test_name, status2|
315
+ unless statuses1_for_test.key?(test_name)
316
+ # This test has been added
317
+ statuses[tests_suite] = [] unless statuses.key?(tests_suite)
318
+ statuses[tests_suite] << [test_name, status2]
319
+ end
320
+ end
321
+ else
322
+ # All test statuses have been removed
323
+ statuses[tests_suite] = tests_info.map { |(test_name, _test_status)| [test_name, 'deleted'] }
324
+ end
325
+ end
326
+ statuses2.each do |tests_suite, tests_info|
327
+ # All test statuses have been added
328
+ statuses[tests_suite] = tests_info unless statuses1.key?(tests_suite)
329
+ end
330
+ statuses
331
+ end
332
+
333
+ end
334
+
335
+ end