modsvaskr 0.0.2 → 0.1.2

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: 483f3213af06eaeb079ed489e90635658d397545c581f2ad9e993d0f742d1459
4
- data.tar.gz: d8665abda6db5e035e290ebce3b34bd7a0e99ead0f0cb6158df5f012918d04da
3
+ metadata.gz: 759a86a5ea5ddbba8ed9bcf56ece81b83324dca028af137e2bf948402f5900ad
4
+ data.tar.gz: 596259ae4f2e3904ac555eef656bd6be5f7ab18047fc87ba50263978f67a5f5d
5
5
  SHA512:
6
- metadata.gz: a46b72a074741215bbd7fb7c1665038d697e5d4cf088cf3cb25828bb8d4a4595d1bd7b10779bc56f156973c6892e4c70e8fb69120537ea716029094c799bd78e
7
- data.tar.gz: f7e8487eb869654d5e3b453d243fa68de84819c2e2823c6ccf9dee4ca15107106875e060ab607ce6fd720158a979a257de748eef7e66051b3ac7a0f7a57c0c27
6
+ metadata.gz: 37902323f88d35bcdf80abb62a1e1c14d8db78dd4ecf37aa0d0a8afabba6b17fad5f653863f5f08f9a5309665bef39838ad120315919f7dca1b9a01de185ef3b
7
+ data.tar.gz: a24f76d9bb884fde1e1046b28e5082da0cfc451767c272a5e70eb22a0b203d211b08f92cdec173eafbbbae07b842c27f63dfb63f94fde5827ca280e5bfb2e0de
@@ -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,339 @@
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
+ unless @config.no_prompt
208
+ # First, flush stdin of any pending character
209
+ $stdin.getc while !select([$stdin], nil, nil, 2).nil?
210
+ end
211
+ out "We are going to start again in #{@game.timeout_interrupt_tests_secs} seconds. Press Enter now to interrupt it."
212
+ key_pressed =
213
+ begin
214
+ Timeout.timeout(@game.timeout_interrupt_tests_secs) { $stdin.gets }
215
+ rescue
216
+ nil
217
+ end
218
+ if key_pressed
219
+ log "[ In-game testing #{@game.name} ] - Run interrupted by user."
220
+ # 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.
221
+ break
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end
228
+
229
+ private
230
+
231
+ # Start an AutoTest monitoring session.
232
+ # This allows checking for test statuses differences easily.
233
+ #
234
+ # Parameters::
235
+ # * *on_auto_test_statuses_diffs* (Proc): Code called when a in-game test status has changed
236
+ # * Parameters::
237
+ # * *in_game_tests_suite* (Symbol): The in-game tests suite for which test statuses have changed
238
+ # * *in_game_tests_statuses* (Hash<String,String>): Tests statuses, per test name
239
+ # * Proc: Code called with monitoring on
240
+ def with_auto_test_monitoring(on_auto_test_statuses_diffs:)
241
+ @last_auto_test_statuses = {}
242
+ @on_auto_test_statuses_diffs = on_auto_test_statuses_diffs
243
+ yield
244
+ end
245
+
246
+ # Check AutoTest test statuses differences, and call some code in case of changes in those statuses.
247
+ # Remember the last checked statuses to only find diffs on next call.
248
+ # Prerequisites: To be called inside a with_auto_test_monitoring block
249
+ #
250
+ # Result::
251
+ # * Hash<Symbol, Array<[String, String]> >: Ordered list of AutoTest [test name, test status], per AutoTest tests suite
252
+ def check_auto_test_statuses
253
+ # Log a diff in tests
254
+ new_statuses = auto_test_statuses
255
+ diff_statuses = diff_statuses(@last_auto_test_statuses, new_statuses)
256
+ unless diff_statuses.empty?
257
+ # Tests have progressed
258
+ log "[ In-game testing #{@game.name} ] - #{diff_statuses.size} tests suites have statuses changes:"
259
+ diff_statuses.each do |tests_suite, tests_statuses|
260
+ log "[ In-game testing #{@game.name} ] * #{tests_suite}:"
261
+ tests_statuses.each do |(test_name, test_status)|
262
+ log "[ In-game testing #{@game.name} ] * #{test_name}: #{test_status}"
263
+ end
264
+ @on_auto_test_statuses_diffs.call(tests_suite, Hash[tests_statuses])
265
+ end
266
+ end
267
+ # Remember the current statuses
268
+ @last_auto_test_statuses = new_statuses
269
+ @last_auto_test_statuses
270
+ end
271
+
272
+ # Get the list of AutoTest statuses
273
+ #
274
+ # Result::
275
+ # * Hash<Symbol, Array<[String, String]> >: Ordered list of AutoTest [test name, test status], per AutoTest tests suite
276
+ def auto_test_statuses
277
+ statuses = {}
278
+ `dir "#{@game.path}/Data/SKSE/Plugins/StorageUtilData" /B`.split("\n").each do |file|
279
+ if file =~ /^AutoTest_(.+)_Statuses\.json$/
280
+ auto_test_suite = $1.downcase.to_sym
281
+ # Careful as this JSON file can be written by Papyrus that treat strings as case insensitive.
282
+ # cf. https://github.com/xanderdunn/skaar/wiki/Common-Tasks
283
+ statuses[auto_test_suite] = JSON.parse(File.read("#{@game.path}/Data/SKSE/Plugins/StorageUtilData/#{file}"))['string'].map do |test_name, test_status|
284
+ [test_name.downcase, test_status.downcase]
285
+ end
286
+ end
287
+ end
288
+ statuses
289
+ end
290
+
291
+ # Diff between test statuses.
292
+ #
293
+ # Parameters::
294
+ # * *statuses1* (Hash<Symbol, Array<[String, String]> >): Test statuses, per test name (in a sorted list), per tests suite
295
+ # * *statuses2* (Hash<Symbol, Array<[String, String]> >): Test statuses, per test name (in a sorted list), per tests suite
296
+ # Result::
297
+ # * Hash<Symbol, Array<[String, String]> >: statuses2 - statuses1
298
+ def diff_statuses(statuses1, statuses2)
299
+ statuses = {}
300
+ statuses1.each do |tests_suite, tests_info|
301
+ if statuses2.key?(tests_suite)
302
+ # Handle Hashes as it will be faster
303
+ statuses1_for_test = Hash[tests_info]
304
+ statuses2_for_test = Hash[statuses2[tests_suite]]
305
+ statuses1_for_test.each do |test_name, status1|
306
+ if statuses2_for_test.key?(test_name)
307
+ if statuses2_for_test[test_name] != status1
308
+ # Change in status
309
+ statuses[tests_suite] = [] unless statuses.key?(tests_suite)
310
+ statuses[tests_suite] << [test_name, statuses2_for_test[test_name]]
311
+ end
312
+ else
313
+ # This test has been removed
314
+ statuses[tests_suite] = [] unless statuses.key?(tests_suite)
315
+ statuses[tests_suite] << [test_name, 'deleted']
316
+ end
317
+ end
318
+ statuses2_for_test.each do |test_name, status2|
319
+ unless statuses1_for_test.key?(test_name)
320
+ # This test has been added
321
+ statuses[tests_suite] = [] unless statuses.key?(tests_suite)
322
+ statuses[tests_suite] << [test_name, status2]
323
+ end
324
+ end
325
+ else
326
+ # All test statuses have been removed
327
+ statuses[tests_suite] = tests_info.map { |(test_name, _test_status)| [test_name, 'deleted'] }
328
+ end
329
+ end
330
+ statuses2.each do |tests_suite, tests_info|
331
+ # All test statuses have been added
332
+ statuses[tests_suite] = tests_info unless statuses1.key?(tests_suite)
333
+ end
334
+ statuses
335
+ end
336
+
337
+ end
338
+
339
+ end