modsvaskr 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/bin/modsvaskr +12 -0
- data/lib/modsvaskr/config.rb +42 -0
- data/lib/modsvaskr/game.rb +61 -0
- data/lib/modsvaskr/games/skyrim_se.rb +91 -0
- data/lib/modsvaskr/logger.rb +25 -0
- data/lib/modsvaskr/run_cmd.rb +22 -0
- data/lib/modsvaskr/tests_runner.rb +430 -0
- data/lib/modsvaskr/tests_suite.rb +69 -0
- data/lib/modsvaskr/tests_suites/exterior_cell.rb +130 -0
- data/lib/modsvaskr/tests_suites/interior_cell.rb +76 -0
- data/lib/modsvaskr/tests_suites/npc.rb +52 -0
- data/lib/modsvaskr/ui.rb +167 -0
- data/lib/modsvaskr/version.rb +5 -0
- data/lib/modsvaskr/xedit.rb +53 -0
- metadata +87 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 35bcc4ccf0026926ed167c88235b0da1d1b86735e348b9a5059203ed4389ab89
|
4
|
+
data.tar.gz: 550b218d7b1f3d5c779d68a8e303da98117484219d2009848a31b5f51a3714a9
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: deefc507a16b05a96c799f3749dc519d9b3c286bb8a035066e844102e4c6a86138642bd057046b1bd6ea06493a95344d9282eae514920dcfe477b624c8f41f86
|
7
|
+
data.tar.gz: d7b7d7e0944f24eb07cbbbcc0f9bc02c56b687ef4d9dcc8c5edbe3a136c23720e58c61583179bb4b50eac69c698ed53567f66466e743a5db03b1b86cb3a130e4
|
data/bin/modsvaskr
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# require 'mod_organizer'
|
3
|
+
require 'modsvaskr/config'
|
4
|
+
require 'modsvaskr/ui'
|
5
|
+
|
6
|
+
begin
|
7
|
+
Modsvaskr::Ui.new(config: Modsvaskr::Config.new('./modsvaskr.yaml')).run
|
8
|
+
rescue
|
9
|
+
puts "Unhandled exception: #{$!}\n#{$!.backtrace.join("\n")}"
|
10
|
+
puts 'Press Enter to exit.'
|
11
|
+
$stdin.gets
|
12
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
require 'modsvaskr/game'
|
3
|
+
require 'modsvaskr/xedit'
|
4
|
+
|
5
|
+
module Modsvaskr
|
6
|
+
|
7
|
+
# Configuration
|
8
|
+
class Config
|
9
|
+
|
10
|
+
# Constructor
|
11
|
+
#
|
12
|
+
# Parameters::
|
13
|
+
# * *file* (String): File containing configuration
|
14
|
+
def initialize(file)
|
15
|
+
@config = YAML.load(File.read(file))
|
16
|
+
end
|
17
|
+
|
18
|
+
# Get the games list
|
19
|
+
#
|
20
|
+
# Result::
|
21
|
+
# * Array<Game>: List of games
|
22
|
+
def games
|
23
|
+
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)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
@games
|
30
|
+
end
|
31
|
+
|
32
|
+
# Return the xEdit path
|
33
|
+
#
|
34
|
+
# Result::
|
35
|
+
# * String: The xEdit path
|
36
|
+
def xedit_path
|
37
|
+
@config['xedit']
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
require 'modsvaskr/logger'
|
3
|
+
require 'modsvaskr/run_cmd'
|
4
|
+
|
5
|
+
module Modsvaskr
|
6
|
+
|
7
|
+
# Common functionality for any Game
|
8
|
+
class Game
|
9
|
+
|
10
|
+
include Logger, RunCmd
|
11
|
+
|
12
|
+
# Constructor
|
13
|
+
#
|
14
|
+
# Parameters::
|
15
|
+
# * *config* (Config): The config
|
16
|
+
# * *game_info* (Hash<String,Object>): Game info:
|
17
|
+
# * *name* (String): Game name
|
18
|
+
# * *path* (String): Game installation dir
|
19
|
+
def initialize(config, game_info)
|
20
|
+
@config = config
|
21
|
+
@game_info = game_info
|
22
|
+
@name = name
|
23
|
+
init if self.respond_to?(:init)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Return the game name
|
27
|
+
#
|
28
|
+
# Result::
|
29
|
+
# * String: Game name
|
30
|
+
def name
|
31
|
+
@game_info['name']
|
32
|
+
end
|
33
|
+
|
34
|
+
# Return the game path
|
35
|
+
#
|
36
|
+
# Result::
|
37
|
+
# * String: Game path
|
38
|
+
def path
|
39
|
+
@game_info['path']
|
40
|
+
end
|
41
|
+
|
42
|
+
# Return the launch executable
|
43
|
+
#
|
44
|
+
# Result::
|
45
|
+
# * String: Launch executable
|
46
|
+
def launch_exe
|
47
|
+
@game_info['launch_exe']
|
48
|
+
end
|
49
|
+
|
50
|
+
# Return an xEdit instance for this game
|
51
|
+
#
|
52
|
+
# Result::
|
53
|
+
# * Xedit: The xEdit instance
|
54
|
+
def xedit
|
55
|
+
@xedit = Xedit.new(@config.xedit_path, path) unless defined?(@xedit)
|
56
|
+
@xedit
|
57
|
+
end
|
58
|
+
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
require 'open-uri'
|
2
|
+
require 'tmpdir'
|
3
|
+
require 'nokogiri'
|
4
|
+
|
5
|
+
module Modsvaskr
|
6
|
+
|
7
|
+
module Games
|
8
|
+
|
9
|
+
# Handle a Skyrim installation
|
10
|
+
class SkyrimSe < Game
|
11
|
+
|
12
|
+
SEVEN_ZIP_CMD = {
|
13
|
+
dir: 'C:\Program Files\7-Zip',
|
14
|
+
exe: '7z.exe'
|
15
|
+
}
|
16
|
+
|
17
|
+
# Initialize the game
|
18
|
+
# [API] - This method is optional
|
19
|
+
def init
|
20
|
+
@tmp_dir = "#{Dir.tmpdir}/modsvaskr"
|
21
|
+
end
|
22
|
+
|
23
|
+
# Complete the game menu
|
24
|
+
# [API] - This method is optional
|
25
|
+
#
|
26
|
+
# Parameters::
|
27
|
+
# * *menu* (CursesMenu): Menu to complete
|
28
|
+
def complete_game_menu(menu)
|
29
|
+
menu.item 'Install SKSE64' do
|
30
|
+
install_skse64
|
31
|
+
puts 'Press Enter to continue...'
|
32
|
+
$stdin.gets
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Get the game running executable name (that can be found in a tasks manager)
|
37
|
+
# [API] - This method is mandatory
|
38
|
+
#
|
39
|
+
# Result::
|
40
|
+
# * String: The running exe name
|
41
|
+
def running_exe
|
42
|
+
'SkyrimSE.exe'
|
43
|
+
end
|
44
|
+
|
45
|
+
# List of default esps present in the game (the ones in the Data folder when 0 mod is being used)
|
46
|
+
#
|
47
|
+
# Result::
|
48
|
+
# * Array<String>: List of esp/esm/esl base file names.
|
49
|
+
def game_esps
|
50
|
+
%w[
|
51
|
+
skyrim.esm
|
52
|
+
update.esm
|
53
|
+
dawnguard.esm
|
54
|
+
hearthfires.esm
|
55
|
+
dragonborn.esm
|
56
|
+
]
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
# Install SKSE64 corresponding to our game
|
62
|
+
def install_skse64
|
63
|
+
doc = Nokogiri::HTML(open('https://skse.silverlock.org/'))
|
64
|
+
p_element = doc.css('p').find { |el| el.text.strip =~ /^Current SE build .+: 7z archive$/ }
|
65
|
+
if p_element.nil?
|
66
|
+
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.'
|
67
|
+
else
|
68
|
+
url = "https://skse.silverlock.org/#{p_element.at('a')['href']}"
|
69
|
+
path = "#{@tmp_dir}/skse64.7z"
|
70
|
+
FileUtils.mkdir_p File.dirname(path)
|
71
|
+
log "Download from #{url} => #{path}..."
|
72
|
+
open(url, 'rb') do |web_io|
|
73
|
+
File.write(path, web_io.read, mode: 'wb')
|
74
|
+
end
|
75
|
+
skse64_tmp_dir = "#{@tmp_dir}/skse64"
|
76
|
+
log "Unzip into #{skse64_tmp_dir}..."
|
77
|
+
FileUtils.rm_rf skse64_tmp_dir
|
78
|
+
FileUtils.mkdir_p skse64_tmp_dir
|
79
|
+
run_cmd SEVEN_ZIP_CMD, args: ['x', "\"#{path}\"", "-o\"#{skse64_tmp_dir}\"", '-r']
|
80
|
+
skse64_subdir = Dir.glob("#{skse64_tmp_dir}/*").first
|
81
|
+
log "Move files from #{skse64_subdir} to #{self.path}..."
|
82
|
+
FileUtils.cp_r "#{skse64_subdir}/.", self.path, remove_destination: true
|
83
|
+
log 'SKSE64 installed successfully.'
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
end
|
88
|
+
|
89
|
+
end
|
90
|
+
|
91
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Modsvaskr
|
2
|
+
|
3
|
+
# Mixin adding logging functionality, both on screen and in file
|
4
|
+
module Logger
|
5
|
+
|
6
|
+
class << self
|
7
|
+
attr_accessor :log_file
|
8
|
+
end
|
9
|
+
@log_file = File.expand_path('Modsvaskr.log')
|
10
|
+
|
11
|
+
# Log on screen and in log file
|
12
|
+
#
|
13
|
+
# Parameters::
|
14
|
+
# * *msg* (String): Message to log
|
15
|
+
def log(msg)
|
16
|
+
complete_msg = "[ #{Time.now.strftime('%F %T')} ] - #{msg}"
|
17
|
+
puts complete_msg
|
18
|
+
File.open(Logger.log_file, 'a') do |f|
|
19
|
+
f.puts complete_msg
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Modsvaskr
|
2
|
+
|
3
|
+
module RunCmd
|
4
|
+
|
5
|
+
# Run a given command with eventual parameters
|
6
|
+
#
|
7
|
+
# Parameters::
|
8
|
+
# * *cmd* (Hash<Symbol,Object>): The command description:
|
9
|
+
# * *dir* (String): Directory in which the command is found
|
10
|
+
# * *exe* (String): Name of the executable of the command
|
11
|
+
# * *args* (Array<String>): Default arguments of the command [default = []]
|
12
|
+
# * *args* (Array<String>): Additional arguments to give the command [default: []]
|
13
|
+
def run_cmd(cmd, args: [])
|
14
|
+
Dir.chdir cmd[:dir] do
|
15
|
+
cmd_line = "#{cmd[:exe]} #{((cmd.key?(:args) ? cmd[:args] : []) + args).join(' ')}"
|
16
|
+
raise "Unable to execute command line from \"#{dir}\": #{cmd_line}" unless system cmd_line
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
@@ -0,0 +1,430 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'time'
|
3
|
+
require 'modsvaskr/tests_suite'
|
4
|
+
|
5
|
+
module Modsvaskr
|
6
|
+
|
7
|
+
class TestsRunner
|
8
|
+
|
9
|
+
include Logger
|
10
|
+
|
11
|
+
# Constructor.
|
12
|
+
# Default values are for a standard Skyrim SE installation.
|
13
|
+
#
|
14
|
+
# Parameters::
|
15
|
+
# * *game* (Game): Game for which we run tests
|
16
|
+
# * *tests_poll_secs* (Integer): Number of seconds to poll for the game run and tests changes [default: 5]
|
17
|
+
# * *timeout_frozen_tests_secs* (Integer): Time out (in seconds) between tests updates: if no test has been updated before this timeout, consider the game has crashed. [default: 300]
|
18
|
+
def initialize(
|
19
|
+
game,
|
20
|
+
tests_poll_secs: 5,
|
21
|
+
timeout_frozen_tests_secs: 300
|
22
|
+
)
|
23
|
+
@game = game
|
24
|
+
@tests_poll_secs = tests_poll_secs
|
25
|
+
@timeout_frozen_tests_secs = timeout_frozen_tests_secs
|
26
|
+
# Parse tests suites
|
27
|
+
@tests_suites = Hash[Dir.glob("#{__dir__}/tests_suites/*.rb").map do |tests_suite_file|
|
28
|
+
tests_suite = File.basename(tests_suite_file, '.rb').to_sym
|
29
|
+
require "#{__dir__}/tests_suites/#{tests_suite}.rb"
|
30
|
+
[
|
31
|
+
tests_suite,
|
32
|
+
TestsSuites.const_get(tests_suite.to_s.split('_').collect(&:capitalize).join.to_sym).new(tests_suite, @game)
|
33
|
+
]
|
34
|
+
end]
|
35
|
+
@tests_info_file = "#{@game.path}/Data/Modsvaskr/Tests/TestsInfo.json"
|
36
|
+
end
|
37
|
+
|
38
|
+
# Return tests suites
|
39
|
+
#
|
40
|
+
# Result::
|
41
|
+
# * Array<Symbol>: List of tests suites
|
42
|
+
def tests_suites
|
43
|
+
@tests_suites.keys
|
44
|
+
end
|
45
|
+
|
46
|
+
# Return test names for a given tests suite
|
47
|
+
#
|
48
|
+
# Parameters::
|
49
|
+
# * *tests_suite* (Symbol): The tests suite
|
50
|
+
# Result::
|
51
|
+
# * Array<String>: Test names of this suite
|
52
|
+
def discover_tests_for(tests_suite)
|
53
|
+
discovered_tests = @tests_suites[tests_suite].discover_tests
|
54
|
+
# Complete our tests information
|
55
|
+
complete_info = tests_info
|
56
|
+
discovered_tests.each do |test_name, test_info|
|
57
|
+
complete_info[tests_suite] = {} unless complete_info.key?(tests_suite)
|
58
|
+
complete_info[tests_suite][test_name] = test_info
|
59
|
+
end
|
60
|
+
update_tests_info(complete_info)
|
61
|
+
discovered_tests.keys
|
62
|
+
end
|
63
|
+
|
64
|
+
# Get test statuses for a given tests suite
|
65
|
+
#
|
66
|
+
# Parameters::
|
67
|
+
# * *tests_suite* (Symbol): The tests suite
|
68
|
+
# Result::
|
69
|
+
# * Array<[String, String]>: Ordered list of couples [test name, test status]
|
70
|
+
def statuses_for(tests_suite)
|
71
|
+
@tests_suites[tests_suite].statuses
|
72
|
+
end
|
73
|
+
|
74
|
+
# Set test statuses for a given tests suite
|
75
|
+
#
|
76
|
+
# Parameters::
|
77
|
+
# * *tests_suite* (Symbol): The tests suite
|
78
|
+
# * *statuses* (Array<[String, String]>): Ordered list of couples [test name, test status])
|
79
|
+
def set_statuses_for(tests_suite, statuses)
|
80
|
+
@tests_suites[tests_suite].set_statuses(statuses)
|
81
|
+
end
|
82
|
+
|
83
|
+
# Return test information
|
84
|
+
#
|
85
|
+
# Parameters::
|
86
|
+
# * *tests_suite* (Symbol): The tests suite
|
87
|
+
# * *test_name* (String): The test name
|
88
|
+
# Result::
|
89
|
+
# * Hash<Symbol,Object>: The test information (all properties are optional):
|
90
|
+
# * *name* (String): The test full name
|
91
|
+
def test_info(tests_suite, test_name)
|
92
|
+
tests_info.dig(tests_suite, test_name) || {}
|
93
|
+
end
|
94
|
+
|
95
|
+
# Clear tests for a given tests suite
|
96
|
+
#
|
97
|
+
# Parameters::
|
98
|
+
# * *tests_suite* (Symbol): The tests suite
|
99
|
+
def clear_tests_for(tests_suite)
|
100
|
+
@tests_suites[tests_suite].clear_tests
|
101
|
+
end
|
102
|
+
|
103
|
+
# Run tests in a loop until they are all tested
|
104
|
+
#
|
105
|
+
# Parameters::
|
106
|
+
# * *selected_tests* (Hash<Symbol, Array<String> >): Ordered list of tests to be run, per tests suite
|
107
|
+
def run(selected_tests)
|
108
|
+
# Test names (ordered) to be performed in game, per tests suite
|
109
|
+
# Hash< Symbol, Array<String> >
|
110
|
+
in_game_tests = {}
|
111
|
+
selected_tests.each do |tests_suite, suite_selected_tests|
|
112
|
+
if @tests_suites[tests_suite].respond_to?(:run_test)
|
113
|
+
# Simple synchronous tests
|
114
|
+
suite_selected_tests.each do |test_name|
|
115
|
+
# Store statuses after each test just in case of crash
|
116
|
+
set_statuses_for(tests_suite, [[test_name, @tests_suites[tests_suite].run_test(test_name)]])
|
117
|
+
end
|
118
|
+
end
|
119
|
+
if @tests_suites[tests_suite].respond_to?(:auto_tests_for)
|
120
|
+
# We run the tests from the game itself, using AutoTest mod.
|
121
|
+
in_game_tests[tests_suite] = suite_selected_tests
|
122
|
+
end
|
123
|
+
end
|
124
|
+
unless in_game_tests.empty?
|
125
|
+
# Get the list of AutoTest we have to run and that we will monitor
|
126
|
+
in_game_auto_tests = in_game_tests.inject({}) do |merged_auto_tests, (tests_suite, suite_selected_tests)|
|
127
|
+
merged_auto_tests.merge(@tests_suites[tests_suite].auto_tests_for(suite_selected_tests)) do |auto_test_suite, auto_test_tests1, auto_test_tests2|
|
128
|
+
(auto_test_tests1 + auto_test_tests2).uniq
|
129
|
+
end
|
130
|
+
end
|
131
|
+
in_game_auto_tests.each do |auto_test_suite, auto_test_tests|
|
132
|
+
# Write the JSON file that contains the list of tests to run
|
133
|
+
File.write(
|
134
|
+
"#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_#{auto_test_suite}_Run.json",
|
135
|
+
JSON.pretty_generate(
|
136
|
+
'stringList' => {
|
137
|
+
'tests_to_run' => auto_test_tests
|
138
|
+
}
|
139
|
+
)
|
140
|
+
)
|
141
|
+
# Clear the AutoTest test statuses
|
142
|
+
File.unlink("#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_#{auto_test_suite}_Statuses.json") if File.exist?("#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_#{auto_test_suite}_Statuses.json")
|
143
|
+
end
|
144
|
+
auto_test_config_file = "#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_Config.json"
|
145
|
+
# Write the JSON file that contains the configuration of the AutoTest tests runner
|
146
|
+
File.write(
|
147
|
+
auto_test_config_file,
|
148
|
+
JSON.pretty_generate(
|
149
|
+
'string' => {
|
150
|
+
'on_start' => 'run',
|
151
|
+
'on_stop' => 'exit'
|
152
|
+
}
|
153
|
+
)
|
154
|
+
)
|
155
|
+
puts ''
|
156
|
+
puts '=========================================='
|
157
|
+
puts '= In-game tests are about to be launched ='
|
158
|
+
puts '=========================================='
|
159
|
+
puts ''
|
160
|
+
puts '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):'
|
161
|
+
puts '* Load the game save you want to test (or start a new game).'
|
162
|
+
puts ''
|
163
|
+
puts 'This will execute all in-game tests automatically.'
|
164
|
+
puts ''
|
165
|
+
puts 'It is possible that the game crashes during tests:'
|
166
|
+
puts '* 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.'
|
167
|
+
puts '* In case of game crash (CTD), the Modsvaskr test framework will relaunch it automatically and resume testing from when it crashed.'
|
168
|
+
puts '* In case of repeated CTD on the same test, the Modsvaskr test framework will detect it and skip the crashing test automatically.'
|
169
|
+
puts '* 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.'
|
170
|
+
puts ''
|
171
|
+
puts 'If you want to interrupt in-game testing: invoke the console with ~ key and type stop_tests followed by Enter.'
|
172
|
+
puts ''
|
173
|
+
puts 'Press enter to start in-game testing (this will lauch your game automatically)...'
|
174
|
+
$stdin.gets
|
175
|
+
idx_launch = 0
|
176
|
+
old_statuses = auto_test_statuses
|
177
|
+
loop do
|
178
|
+
launch_game(use_autoload: idx_launch > 0)
|
179
|
+
idx_launch += 1
|
180
|
+
monitor_game_tests
|
181
|
+
# Check tests again
|
182
|
+
new_statuses = auto_test_statuses
|
183
|
+
# Update the tests results based on what has been run in-game
|
184
|
+
in_game_tests.each do |tests_suite, suite_selected_tests|
|
185
|
+
@tests_suites[tests_suite].set_statuses(
|
186
|
+
@tests_suites[tests_suite].
|
187
|
+
parse_auto_tests_statuses_for(suite_selected_tests, new_statuses).
|
188
|
+
select { |(test_name, _test_status)| suite_selected_tests.include?(test_name) }
|
189
|
+
)
|
190
|
+
end
|
191
|
+
log 'Test statuses after game run:'
|
192
|
+
print_test_statuses(new_statuses)
|
193
|
+
# Careful as this JSON file can be written by Papyrus that treat strings as case insensitive.
|
194
|
+
# cf. https://github.com/xanderdunn/skaar/wiki/Common-Tasks
|
195
|
+
auto_test_config = Hash[JSON.parse(File.read(auto_test_config_file))['string'].map { |key, value| [key.downcase, value.downcase] }]
|
196
|
+
if auto_test_config.dig('stopped_by') == 'user'
|
197
|
+
log 'Tests have been stopped by user. Stop looping.'
|
198
|
+
break
|
199
|
+
end
|
200
|
+
if auto_test_config.dig('tests_execution') == 'end'
|
201
|
+
log 'Tests have finished running. Stop looping.'
|
202
|
+
break
|
203
|
+
end
|
204
|
+
if new_statuses == old_statuses
|
205
|
+
log 'No changes in AutoTest tests statuses in the game run. Stop looping.'
|
206
|
+
break
|
207
|
+
end
|
208
|
+
old_statuses = new_statuses
|
209
|
+
# We will start again. Leave some time to interrupt if we want.
|
210
|
+
log 'We are going to start again in 10 seconds. Press Enter now to interrupt it.'
|
211
|
+
key_pressed =
|
212
|
+
begin
|
213
|
+
Timeout.timeout(10) { $stdin.gets }
|
214
|
+
rescue
|
215
|
+
nil
|
216
|
+
end
|
217
|
+
if key_pressed
|
218
|
+
log 'Run interrupted by user.'
|
219
|
+
break
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
223
|
+
end
|
224
|
+
|
225
|
+
private
|
226
|
+
|
227
|
+
# Return all tests info.
|
228
|
+
# Keep a cache of it.
|
229
|
+
#
|
230
|
+
# Result::
|
231
|
+
# * Hash< Symbol, Hash< String, Hash<Symbol,Object> > >: The tests info, per test name, per tests suite
|
232
|
+
def tests_info
|
233
|
+
unless defined?(@tests_info_cache)
|
234
|
+
@tests_info_cache =
|
235
|
+
if File.exist?(@tests_info_file)
|
236
|
+
Hash[JSON.parse(File.read(@tests_info_file)).map do |tests_suite_str, tests_suite_info|
|
237
|
+
[
|
238
|
+
tests_suite_str.to_sym,
|
239
|
+
Hash[tests_suite_info.map { |test_name, test_info| [test_name, test_info.transform_keys(&:to_sym)] }]
|
240
|
+
]
|
241
|
+
end]
|
242
|
+
else
|
243
|
+
{}
|
244
|
+
end
|
245
|
+
end
|
246
|
+
@tests_info_cache
|
247
|
+
end
|
248
|
+
|
249
|
+
# Update tests info.
|
250
|
+
#
|
251
|
+
# Parameters::
|
252
|
+
# * *tests_info* (Hash< Symbol, Hash< String, Hash<Symbol,Object> > >): The tests info, per test name, per tests suite
|
253
|
+
def update_tests_info(tests_info)
|
254
|
+
# Persist the tests information on disk
|
255
|
+
FileUtils.mkdir_p File.dirname(@tests_info_file)
|
256
|
+
File.write(@tests_info_file, JSON.pretty_generate(tests_info))
|
257
|
+
@tests_info_cache = tests_info
|
258
|
+
end
|
259
|
+
|
260
|
+
# Launch the game, and wait for launch to be successful (get a PID)
|
261
|
+
#
|
262
|
+
# Parameters::
|
263
|
+
# * *use_autoload* (Boolean): If true, then launch the game using AutoLoad
|
264
|
+
def launch_game(use_autoload:)
|
265
|
+
# Launch the game
|
266
|
+
@idx_launch = 0 unless defined?(@idx_launch)
|
267
|
+
Dir.chdir(@game.path) do
|
268
|
+
if use_autoload
|
269
|
+
log "Launch Game (##{@idx_launch}) using AutoLoad..."
|
270
|
+
system "\"#{@game.path}/Data/AutoLoad.cmd\" auto_test"
|
271
|
+
else
|
272
|
+
log "Launch Game (##{@idx_launch}) using configured launcher..."
|
273
|
+
system "\"#{@game.path}/#{@game.launch_exe}\""
|
274
|
+
end
|
275
|
+
end
|
276
|
+
@idx_launch += 1
|
277
|
+
# The game launches asynchronously, so just wait a little bit and check for the process
|
278
|
+
sleep 10
|
279
|
+
tasklist_stdout = nil
|
280
|
+
loop do
|
281
|
+
tasklist_stdout = `tasklist | find "#{@game.running_exe}"`.strip
|
282
|
+
break unless tasklist_stdout.empty?
|
283
|
+
log "#{@game.running_exe} is not running. Wait for its startup..."
|
284
|
+
sleep 1
|
285
|
+
end
|
286
|
+
@game_pid = Integer(tasklist_stdout.split(' ')[1])
|
287
|
+
log "#{@game.running_exe} has started with PID #{@game_pid}"
|
288
|
+
end
|
289
|
+
|
290
|
+
# Kill the game, and wait till it is killed
|
291
|
+
def kill_game
|
292
|
+
first_time = true
|
293
|
+
loop do
|
294
|
+
system "taskkill #{first_time ? '' : '/F'} /pid #{@game_pid}"
|
295
|
+
first_time = false
|
296
|
+
sleep 1
|
297
|
+
tasklist_stdout = `tasklist | find "#{@game.running_exe}"`.strip
|
298
|
+
break if tasklist_stdout.empty?
|
299
|
+
log "#{@game.running_exe} is still running. Wait for its kill..."
|
300
|
+
sleep 5
|
301
|
+
end
|
302
|
+
end
|
303
|
+
|
304
|
+
# Get the list of AutoTest statuses
|
305
|
+
#
|
306
|
+
# Result::
|
307
|
+
# * Hash<String, Array<[String, String]> >: Ordered list of AutoTest [test name, test status], per AutoTest tests suite
|
308
|
+
def auto_test_statuses
|
309
|
+
statuses = {}
|
310
|
+
`dir "#{@game.path}/Data/SKSE/Plugins/StorageUtilData" /B`.split("\n").each do |file|
|
311
|
+
if file =~ /^AutoTest_(.+)_Statuses\.json$/
|
312
|
+
auto_test_suite = $1
|
313
|
+
# Careful as this JSON file can be written by Papyrus that treat strings as case insensitive.
|
314
|
+
# cf. https://github.com/xanderdunn/skaar/wiki/Common-Tasks
|
315
|
+
statuses[auto_test_suite] = JSON.parse(File.read("#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_#{auto_test_suite}_Statuses.json"))['string'].map do |test_name, test_status|
|
316
|
+
[test_name.downcase, test_status.downcase]
|
317
|
+
end
|
318
|
+
end
|
319
|
+
end
|
320
|
+
statuses
|
321
|
+
end
|
322
|
+
|
323
|
+
# Loop while monitoring game tests progression.
|
324
|
+
# If the game exits, then exit also the loop.
|
325
|
+
# In case no test is being updated for a long period of time, kill the game (we consider it was frozen)
|
326
|
+
# Prerequisite: launch_game has to be called before.
|
327
|
+
def monitor_game_tests
|
328
|
+
log 'Start monitoring game testing...'
|
329
|
+
last_time_tests_changed = Time.now
|
330
|
+
# Get a picture of current tests
|
331
|
+
current_statuses = auto_test_statuses
|
332
|
+
loop do
|
333
|
+
still_running = true
|
334
|
+
begin
|
335
|
+
# Process.kill does not work when the game has crashed (the process is still detected as zombie)
|
336
|
+
# still_running = Process.kill(0, @game_pid) == 1
|
337
|
+
tasklist_stdout = `tasklist 2>&1`
|
338
|
+
still_running = tasklist_stdout.split("\n").any? { |line| line =~ /#{Regexp.escape(@game.running_exe)}/ }
|
339
|
+
# log "Tasklist returned no Skyrim:\n#{tasklist_stdout}" unless still_running
|
340
|
+
rescue Errno::ESRCH
|
341
|
+
log "Got error while waiting for #{@game.running_exe} PID: #{$!}"
|
342
|
+
still_running = false
|
343
|
+
end
|
344
|
+
# Log a diff in tests
|
345
|
+
new_statuses = auto_test_statuses
|
346
|
+
diff_statuses = diff_statuses(current_statuses, new_statuses)
|
347
|
+
unless diff_statuses.empty?
|
348
|
+
# Tests have progressed
|
349
|
+
last_time_tests_changed = Time.now
|
350
|
+
log '===== Test statuses changes:'
|
351
|
+
diff_statuses.each do |tests_suite, tests_statuses|
|
352
|
+
log "* #{tests_suite}:"
|
353
|
+
tests_statuses.each do |(test_name, test_status)|
|
354
|
+
log " * #{test_name}: #{test_status}"
|
355
|
+
end
|
356
|
+
end
|
357
|
+
end
|
358
|
+
break unless still_running
|
359
|
+
# If the tests haven't changed for too long, consider the game has frozen, but not crashed. So kill it.
|
360
|
+
if Time.now - last_time_tests_changed > @timeout_frozen_tests_secs
|
361
|
+
log "Last time test have changed is #{last_time_tests_changed.strftime('%F %T')}. Consider the game is frozen, so kill it."
|
362
|
+
kill_game
|
363
|
+
break
|
364
|
+
end
|
365
|
+
current_statuses = new_statuses
|
366
|
+
sleep @tests_poll_secs
|
367
|
+
end
|
368
|
+
log 'End monitoring game testing.'
|
369
|
+
end
|
370
|
+
|
371
|
+
# Diff between test statuses.
|
372
|
+
#
|
373
|
+
# Parameters::
|
374
|
+
# * *statuses1* (Hash<String, Array<[String, String]> >): Test statuses, per test name (in a sorted list), per tests suite
|
375
|
+
# * *statuses2* (Hash<String, Array<[String, String]> >): Test statuses, per test name (in a sorted list), per tests suite
|
376
|
+
# Result::
|
377
|
+
# * Hash<String, Array<[String, String]> >: statuses2 - statuses1
|
378
|
+
def diff_statuses(statuses1, statuses2)
|
379
|
+
statuses = {}
|
380
|
+
statuses1.each do |tests_suite, tests_info|
|
381
|
+
if statuses2.key?(tests_suite)
|
382
|
+
# Handle Hashes as it will be faster
|
383
|
+
statuses1_for_test = Hash[tests_info]
|
384
|
+
statuses2_for_test = Hash[statuses2[tests_suite]]
|
385
|
+
statuses1_for_test.each do |test_name, status1|
|
386
|
+
if statuses2_for_test.key?(test_name)
|
387
|
+
if statuses2_for_test[test_name] != status1
|
388
|
+
# Change in status
|
389
|
+
statuses[tests_suite] = [] unless statuses.key?(tests_suite)
|
390
|
+
statuses[tests_suite] << [test_name, statuses2_for_test[test_name]]
|
391
|
+
end
|
392
|
+
else
|
393
|
+
# This test has been removed
|
394
|
+
statuses[tests_suite] = [] unless statuses.key?(tests_suite)
|
395
|
+
statuses[tests_suite] << [test_name, 'Deleted']
|
396
|
+
end
|
397
|
+
end
|
398
|
+
statuses2_for_test.each do |test_name, status2|
|
399
|
+
unless statuses1_for_test.key?(test_name)
|
400
|
+
# This test has been added
|
401
|
+
statuses[tests_suite] = [] unless statuses.key?(tests_suite)
|
402
|
+
statuses[tests_suite] << [test_name, status2]
|
403
|
+
end
|
404
|
+
end
|
405
|
+
else
|
406
|
+
# All test statuses have been removed
|
407
|
+
statuses[tests_suite] = tests_info.map { |(test_name, _test_status)| [test_name, 'Deleted'] }
|
408
|
+
end
|
409
|
+
end
|
410
|
+
statuses2.each do |tests_suite, tests_info|
|
411
|
+
# All test statuses have been added
|
412
|
+
statuses[tests_suite] = tests_info unless statuses1.key?(tests_suite)
|
413
|
+
end
|
414
|
+
statuses
|
415
|
+
end
|
416
|
+
|
417
|
+
# Print test statuses
|
418
|
+
#
|
419
|
+
# Parameters::
|
420
|
+
# * *statuses* (Hash<String, Array<[String, String]> >): Test statuses, per test name (in a sorted list), per tests suite
|
421
|
+
def print_test_statuses(statuses)
|
422
|
+
statuses.each do |tests_suite, statuses_for_type|
|
423
|
+
next_test_name, _next_test_status = statuses_for_type.find { |(_name, status)| status != 'ok' }
|
424
|
+
log "[ #{tests_suite} ] - #{statuses_for_type.select { |(_name, status)| status == 'ok' }.size} / #{statuses_for_type.size} - Next test to perform: #{next_test_name.nil? ? 'None' : next_test_name}"
|
425
|
+
end
|
426
|
+
end
|
427
|
+
|
428
|
+
end
|
429
|
+
|
430
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
require 'json'
|
3
|
+
require 'modsvaskr/logger'
|
4
|
+
require 'modsvaskr/run_cmd'
|
5
|
+
|
6
|
+
module Modsvaskr
|
7
|
+
|
8
|
+
# Common functionality for any tests suite
|
9
|
+
class TestsSuite
|
10
|
+
|
11
|
+
include Logger, RunCmd
|
12
|
+
|
13
|
+
# Constructor
|
14
|
+
#
|
15
|
+
# Parameters::
|
16
|
+
# * *tests_suite* (Symbol): The tests suite name
|
17
|
+
# * *game* (Game): The game for which this test type is instantiated
|
18
|
+
def initialize(tests_suite, game)
|
19
|
+
@tests_suite = tests_suite
|
20
|
+
@game = game
|
21
|
+
end
|
22
|
+
|
23
|
+
# Get test statuses
|
24
|
+
#
|
25
|
+
# Result::
|
26
|
+
# * Array<[String, String]>: Ordered list of [test name, test status]
|
27
|
+
def statuses
|
28
|
+
File.exist?(json_statuses_file) ? JSON.parse(File.read(json_statuses_file)) : []
|
29
|
+
end
|
30
|
+
|
31
|
+
# Set test statuses.
|
32
|
+
# Add new ones and overwrites existing ones.
|
33
|
+
#
|
34
|
+
# Parameters::
|
35
|
+
# * *statuses* (Array<[String, String]>): Ordered list of [test name, test status]
|
36
|
+
def set_statuses(statuses)
|
37
|
+
current_statuses = self.statuses
|
38
|
+
statuses.each do |(test_name, test_status)|
|
39
|
+
test_status_info = current_statuses.find { |(search_test_name, _search_test_status)| search_test_name == test_name }
|
40
|
+
if test_status_info.nil?
|
41
|
+
# New one. Add it to the end.
|
42
|
+
current_statuses << [test_name, test_status]
|
43
|
+
else
|
44
|
+
# Already existing. Just change its status.
|
45
|
+
test_status_info[1] = test_status
|
46
|
+
end
|
47
|
+
end
|
48
|
+
FileUtils.mkdir_p File.dirname(json_statuses_file)
|
49
|
+
File.write(json_statuses_file, JSON.pretty_generate(current_statuses))
|
50
|
+
end
|
51
|
+
|
52
|
+
# Remove all tests from this suite
|
53
|
+
def clear_tests
|
54
|
+
File.unlink(json_statuses_file) if File.exist?(json_statuses_file)
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
# Get the JSON statuses file name
|
60
|
+
#
|
61
|
+
# Result::
|
62
|
+
# * String: The JSON statuses file name
|
63
|
+
def json_statuses_file
|
64
|
+
"#{@game.path}/Data/Modsvaskr/Tests/Statuses_#{@tests_suite}.json"
|
65
|
+
end
|
66
|
+
|
67
|
+
end
|
68
|
+
|
69
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
module Modsvaskr
|
2
|
+
|
3
|
+
module TestsSuites
|
4
|
+
|
5
|
+
class ExteriorCell < TestsSuite
|
6
|
+
|
7
|
+
# Discover the list of tests information that could be run.
|
8
|
+
# [API] - This method is mandatory
|
9
|
+
#
|
10
|
+
# Result::
|
11
|
+
# * Hash< String, Hash<Symbol,Object> >: Ordered hash of test information, per test name
|
12
|
+
def discover_tests
|
13
|
+
# List of exterior cells coordinates, per worldspace name, per plugin name
|
14
|
+
# Hash< String, Hash< String, Array<[Integer, Integer]> > >
|
15
|
+
exterior_cells = {}
|
16
|
+
@game.xedit.run_script('DumpInfo', only_once: true)
|
17
|
+
CSV.read("#{@game.xedit.install_path}/Edit Scripts/Modsvaskr_ExportedDumpInfo.csv", encoding: 'windows-1251:utf-8').each do |row|
|
18
|
+
esp_name, record_type = row[0..1]
|
19
|
+
if record_type.downcase == 'cell'
|
20
|
+
cell_type, cell_name, cell_x, cell_y = row[3..6]
|
21
|
+
if cell_type == 'cow'
|
22
|
+
if cell_x.nil?
|
23
|
+
log "!!! Invalid record: #{row}"
|
24
|
+
else
|
25
|
+
esp_name.downcase!
|
26
|
+
exterior_cells[esp_name] = {} unless exterior_cells.key?(esp_name)
|
27
|
+
exterior_cells[esp_name][cell_name] = [] unless exterior_cells[esp_name].key?(cell_name)
|
28
|
+
exterior_cells[esp_name][cell_name] << [Integer(cell_x), Integer(cell_y)]
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
# Test only exterior cells that have been changed by mods, and make sure we test the minimum, knowing that each cell loaded in game tests 5x5 cells around
|
34
|
+
vanilla_esps = @game.game_esps
|
35
|
+
vanilla_exterior_cells = vanilla_esps.inject({}) do |merged_worldspaces, esp_name|
|
36
|
+
merged_worldspaces.merge(exterior_cells[esp_name]) do |worldspace, ext_cells1, ext_cells2|
|
37
|
+
(ext_cells1 + ext_cells2).sort.uniq
|
38
|
+
end
|
39
|
+
end
|
40
|
+
changed_exterior_cells = {}
|
41
|
+
exterior_cells.each do |esp_name, esp_exterior_cells|
|
42
|
+
unless vanilla_esps.include?(esp_name)
|
43
|
+
esp_exterior_cells.each do |worldspace, worldspace_exterior_cells|
|
44
|
+
if vanilla_exterior_cells.key?(worldspace)
|
45
|
+
changed_exterior_cells[worldspace] = [] unless changed_exterior_cells.key?(worldspace)
|
46
|
+
changed_exterior_cells[worldspace].concat(vanilla_exterior_cells[worldspace] & worldspace_exterior_cells)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
tests = {}
|
52
|
+
# Value taken from the ini file
|
53
|
+
# TODO: Read it from there (uiGrid)
|
54
|
+
loaded_grid = 5
|
55
|
+
delta_cells = loaded_grid / 2
|
56
|
+
changed_exterior_cells.each do |worldspace, worldspace_exterior_cells|
|
57
|
+
# Make sure we select the minimum cells
|
58
|
+
# Use a Hash of Hashes for the coordinates to speed-up their lookup.
|
59
|
+
remaining_cells = {}
|
60
|
+
worldspace_exterior_cells.each do |(cell_x, cell_y)|
|
61
|
+
remaining_cells[cell_x] = {} unless remaining_cells.key?(cell_x)
|
62
|
+
remaining_cells[cell_x][cell_y] = nil
|
63
|
+
end
|
64
|
+
while !remaining_cells.empty?
|
65
|
+
cell_x, cell_ys = remaining_cells.first
|
66
|
+
cell_y, _nil = cell_ys.first
|
67
|
+
# We want to test cell_x, cell_y.
|
68
|
+
# Knowing that we can test it by loading any cell in the range ((cell_x - delta_cells..cell_x + delta_cells), (cell_y - delta_cells..cell_y + delta_cells)),
|
69
|
+
# check which cell would test the most wanted cells from our list
|
70
|
+
best_cell_x, best_cell_y, best_cell_score = nil, nil, nil
|
71
|
+
(cell_x - delta_cells..cell_x + delta_cells).each do |candidate_cell_x|
|
72
|
+
(cell_y - delta_cells..cell_y + delta_cells).each do |candidate_cell_y|
|
73
|
+
# Check the number of cells that would be tested if we were to test (candidate_cell_x, candidate_cell_y)
|
74
|
+
nbr_tested_cells = remaining_cells.
|
75
|
+
slice(*(candidate_cell_x - delta_cells..candidate_cell_x + delta_cells)).
|
76
|
+
inject(0) { |sum_cells, (_cur_cell_x, cur_cell_ys)| sum_cells + cur_cell_ys.slice(*(candidate_cell_y - delta_cells..candidate_cell_y + delta_cells)).size }
|
77
|
+
if best_cell_score.nil? || nbr_tested_cells > best_cell_score
|
78
|
+
nbr_tested_cells = best_cell_score
|
79
|
+
best_cell_x = candidate_cell_x
|
80
|
+
best_cell_y = candidate_cell_y
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
# Remove the tested cells from the remaining ones
|
85
|
+
(best_cell_x - delta_cells..best_cell_x + delta_cells).each do |cur_cell_x|
|
86
|
+
if remaining_cells.key?(cur_cell_x)
|
87
|
+
(best_cell_y - delta_cells..best_cell_y + delta_cells).each do |cur_cell_y|
|
88
|
+
remaining_cells[cur_cell_x].delete(cur_cell_y)
|
89
|
+
end
|
90
|
+
remaining_cells.delete(cur_cell_x) if remaining_cells[cur_cell_x].empty?
|
91
|
+
end
|
92
|
+
end
|
93
|
+
tests["#{worldspace}/#{best_cell_x}/#{best_cell_y}"] = {
|
94
|
+
name: "Load #{worldspace} cell #{best_cell_x}, #{best_cell_y}"
|
95
|
+
}
|
96
|
+
end
|
97
|
+
end
|
98
|
+
tests
|
99
|
+
end
|
100
|
+
|
101
|
+
# Get the list of tests to be run in the AutoTest mod for a given list of test names.
|
102
|
+
# AutoTest names are case insensitive.
|
103
|
+
# [API] - This method is mandatory for tests needing to be run in-game.
|
104
|
+
#
|
105
|
+
# Parameters::
|
106
|
+
# * *tests* (Array<String>): List of test names
|
107
|
+
# Result::
|
108
|
+
# * Hash<String, Array<String> >: List of AutoTest mod test names, per AutoTest mod tests suite
|
109
|
+
def auto_tests_for(tests)
|
110
|
+
{ 'Locations' => tests }
|
111
|
+
end
|
112
|
+
|
113
|
+
# Set statuses based on the result of AutoTest statuses.
|
114
|
+
# AutoTest names are case insensitive.
|
115
|
+
# [API] - This method is mandatory for tests needing to be run in-game.
|
116
|
+
#
|
117
|
+
# Parameters::
|
118
|
+
# * *tests* (Array<String>): List of test names
|
119
|
+
# * *auto_test_statuses* (Hash<String, Array<[String, String]> >): Ordered list of AutoTest [test name, test status], per AutoTest tests suite
|
120
|
+
# Result::
|
121
|
+
# * Array<[String, String]>: Corresponding list of [test name, test status]
|
122
|
+
def parse_auto_tests_statuses_for(tests, auto_test_statuses)
|
123
|
+
auto_test_statuses.key?('Locations') ? auto_test_statuses['Locations'] : []
|
124
|
+
end
|
125
|
+
|
126
|
+
end
|
127
|
+
|
128
|
+
end
|
129
|
+
|
130
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
module Modsvaskr
|
2
|
+
|
3
|
+
module TestsSuites
|
4
|
+
|
5
|
+
class InteriorCell < TestsSuite
|
6
|
+
|
7
|
+
# Discover the list of tests information that could be run.
|
8
|
+
# [API] - This method is mandatory
|
9
|
+
#
|
10
|
+
# Result::
|
11
|
+
# * Hash< String, Hash<Symbol,Object> >: Ordered hash of test information, per test name
|
12
|
+
def discover_tests
|
13
|
+
# List of interior cells, per plugin name
|
14
|
+
# Hash< String, Array<String> >
|
15
|
+
interior_cells = {}
|
16
|
+
@game.xedit.run_script('DumpInfo', only_once: true)
|
17
|
+
CSV.read("#{@game.xedit.install_path}/Edit Scripts/Modsvaskr_ExportedDumpInfo.csv", encoding: 'windows-1251:utf-8').each do |row|
|
18
|
+
esp_name, record_type = row[0..1]
|
19
|
+
if record_type.downcase == 'cell'
|
20
|
+
cell_type, cell_name = row[3..4]
|
21
|
+
if cell_type == 'coc'
|
22
|
+
esp_name.downcase!
|
23
|
+
interior_cells[esp_name] = [] unless interior_cells.key?(esp_name)
|
24
|
+
interior_cells[esp_name] << cell_name
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
# Test only interior cells that have been changed by mods
|
29
|
+
vanilla_esps = @game.game_esps
|
30
|
+
vanilla_interior_cells = vanilla_esps.map { |esp_name| interior_cells[esp_name] }.flatten.sort.uniq
|
31
|
+
Hash[interior_cells.
|
32
|
+
map { |esp_name, esp_cells| vanilla_esps.include?(esp_name) ? [] : vanilla_interior_cells & esp_cells }.
|
33
|
+
flatten.
|
34
|
+
sort.
|
35
|
+
uniq.
|
36
|
+
map do |cell_name|
|
37
|
+
[
|
38
|
+
cell_name,
|
39
|
+
{
|
40
|
+
name: "Load cell #{cell_name}"
|
41
|
+
}
|
42
|
+
]
|
43
|
+
end
|
44
|
+
]
|
45
|
+
end
|
46
|
+
|
47
|
+
# Get the list of tests to be run in the AutoTest mod for a given list of test names.
|
48
|
+
# AutoTest names are case insensitive.
|
49
|
+
# [API] - This method is mandatory for tests needing to be run in-game.
|
50
|
+
#
|
51
|
+
# Parameters::
|
52
|
+
# * *tests* (Array<String>): List of test names
|
53
|
+
# Result::
|
54
|
+
# * Hash<String, Array<String> >: List of AutoTest mod test names, per AutoTest mod tests suite
|
55
|
+
def auto_tests_for(tests)
|
56
|
+
{ 'Locations' => tests }
|
57
|
+
end
|
58
|
+
|
59
|
+
# Set statuses based on the result of AutoTest statuses.
|
60
|
+
# AutoTest names are case insensitive.
|
61
|
+
# [API] - This method is mandatory for tests needing to be run in-game.
|
62
|
+
#
|
63
|
+
# Parameters::
|
64
|
+
# * *tests* (Array<String>): List of test names
|
65
|
+
# * *auto_test_statuses* (Hash<String, Array<[String, String]> >): Ordered list of AutoTest [test name, test status], per AutoTest tests suite
|
66
|
+
# Result::
|
67
|
+
# * Array<[String, String]>: Corresponding list of [test name, test status]
|
68
|
+
def parse_auto_tests_statuses_for(tests, auto_test_statuses)
|
69
|
+
auto_test_statuses.key?('Locations') ? auto_test_statuses['Locations'] : []
|
70
|
+
end
|
71
|
+
|
72
|
+
end
|
73
|
+
|
74
|
+
end
|
75
|
+
|
76
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module Modsvaskr
|
2
|
+
|
3
|
+
module TestsSuites
|
4
|
+
|
5
|
+
class Npc < TestsSuite
|
6
|
+
|
7
|
+
# Discover the list of tests information that could be run.
|
8
|
+
# [API] - This method is mandatory
|
9
|
+
#
|
10
|
+
# Result::
|
11
|
+
# * Hash< String, Hash<Symbol,Object> >: Ordered hash of test information, per test name
|
12
|
+
def discover_tests
|
13
|
+
tests = {}
|
14
|
+
@game.xedit.run_script('DumpInfo', only_once: true)
|
15
|
+
CSV.read("#{@game.xedit.install_path}/Edit Scripts/Modsvaskr_ExportedDumpInfo.csv", encoding: 'windows-1251:utf-8').each do |row|
|
16
|
+
tests["#{row[0].downcase}/#{row[2].to_i(16)}"] = {
|
17
|
+
name: "Take screenshot of #{row[0]} - #{row[3]}"
|
18
|
+
} if row[1].downcase == 'npc_'
|
19
|
+
end
|
20
|
+
tests
|
21
|
+
end
|
22
|
+
|
23
|
+
# Get the list of tests to be run in the AutoTest mod for a given list of test names.
|
24
|
+
# AutoTest names are case insensitive.
|
25
|
+
# [API] - This method is mandatory for tests needing to be run in-game.
|
26
|
+
#
|
27
|
+
# Parameters::
|
28
|
+
# * *tests* (Array<String>): List of test names
|
29
|
+
# Result::
|
30
|
+
# * Hash<String, Array<String> >: List of AutoTest mod test names, per AutoTest mod tests suite
|
31
|
+
def auto_tests_for(tests)
|
32
|
+
{ 'NPCs' => tests }
|
33
|
+
end
|
34
|
+
|
35
|
+
# Set statuses based on the result of AutoTest statuses.
|
36
|
+
# AutoTest names are case insensitive.
|
37
|
+
# [API] - This method is mandatory for tests needing to be run in-game.
|
38
|
+
#
|
39
|
+
# Parameters::
|
40
|
+
# * *tests* (Array<String>): List of test names
|
41
|
+
# * *auto_test_statuses* (Hash<String, Array<[String, String]> >): Ordered list of AutoTest [test name, test status], per AutoTest tests suite
|
42
|
+
# Result::
|
43
|
+
# * Array<[String, String]>: Corresponding list of [test name, test status]
|
44
|
+
def parse_auto_tests_statuses_for(tests, auto_test_statuses)
|
45
|
+
auto_test_statuses.key?('NPCs') ? auto_test_statuses['NPCs'] : []
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
data/lib/modsvaskr/ui.rb
ADDED
@@ -0,0 +1,167 @@
|
|
1
|
+
require 'csv'
|
2
|
+
require 'launchy'
|
3
|
+
require 'curses_menu'
|
4
|
+
require 'modsvaskr/logger'
|
5
|
+
require 'modsvaskr/tests_runner'
|
6
|
+
require 'modsvaskr/run_cmd'
|
7
|
+
require 'modsvaskr/version'
|
8
|
+
|
9
|
+
module Modsvaskr
|
10
|
+
|
11
|
+
class Ui
|
12
|
+
|
13
|
+
include Logger, RunCmd
|
14
|
+
|
15
|
+
# Constructor
|
16
|
+
#
|
17
|
+
# Parameters::
|
18
|
+
# * *config* (Config): Configuration object
|
19
|
+
def initialize(config:, skyrim: nil)
|
20
|
+
log "Launch Modsvaskr UI v#{Modsvaskr::VERSION} - Logs in #{Logger.log_file}"
|
21
|
+
@config = config
|
22
|
+
end
|
23
|
+
|
24
|
+
# Run the UI
|
25
|
+
def run
|
26
|
+
begin
|
27
|
+
CursesMenu.new 'Modsvaskr - Stronghold of Mods' do |main_menu|
|
28
|
+
@config.games.each do |game|
|
29
|
+
main_menu.item "#{game.name} (#{game.path})" do
|
30
|
+
CursesMenu.new "Modsvaskr - Stronghold of Mods > #{game.name}" do |game_menu|
|
31
|
+
game_menu.item 'Testing' do
|
32
|
+
# Read tests info
|
33
|
+
tests_runner = TestsRunner.new(game)
|
34
|
+
# Selected test names, per test type
|
35
|
+
# Hash< Symbol, Hash< String, nil > >
|
36
|
+
selected_tests_suites = {}
|
37
|
+
CursesMenu.new "Modsvaskr - Stronghold of Mods > #{game.name} > Testing" do |test_menu|
|
38
|
+
tests_runner.tests_suites.each do |tests_suite|
|
39
|
+
statuses_for_suite = tests_runner.statuses_for(tests_suite)
|
40
|
+
all_tests_selected = selected_tests_suites.key?(tests_suite) &&
|
41
|
+
selected_tests_suites[tests_suite].keys.sort == statuses_for_suite.map { |(test_name, _test_status)| test_name }.sort
|
42
|
+
test_menu.item(
|
43
|
+
"[#{
|
44
|
+
if all_tests_selected
|
45
|
+
'*'
|
46
|
+
elsif selected_tests_suites.key?(tests_suite)
|
47
|
+
'+'
|
48
|
+
else
|
49
|
+
' '
|
50
|
+
end
|
51
|
+
}] #{tests_suite} - #{statuses_for_suite.select { |(_name, status)| status == 'ok' }.size} / #{statuses_for_suite.size}",
|
52
|
+
actions: {
|
53
|
+
'd' => {
|
54
|
+
name: 'Details',
|
55
|
+
execute: proc do
|
56
|
+
CursesMenu.new "Modsvaskr - Stronghold of Mods > #{game.name} > Testing > Tests #{tests_suite}" do |tests_suite_menu|
|
57
|
+
statuses_for_suite.each do |(test_name, test_status)|
|
58
|
+
test_selected = selected_tests_suites.key?(tests_suite) && selected_tests_suites[tests_suite].key?(test_name)
|
59
|
+
tests_suite_menu.item "[#{test_selected ? '*' : ' '}] #{test_name} - #{test_status} - #{tests_runner.test_info(tests_suite, test_name)[:name]}" do
|
60
|
+
if test_selected
|
61
|
+
selected_tests_suites[tests_suite].delete(test_name)
|
62
|
+
selected_tests_suites.delete(tests_suite) if selected_tests_suites[tests_suite].empty?
|
63
|
+
else
|
64
|
+
selected_tests_suites[tests_suite] = {} unless selected_tests_suites.key?(tests_suite)
|
65
|
+
selected_tests_suites[tests_suite][test_name] = nil
|
66
|
+
end
|
67
|
+
:menu_refresh
|
68
|
+
end
|
69
|
+
end
|
70
|
+
tests_suite_menu.item 'Back' do
|
71
|
+
:menu_exit
|
72
|
+
end
|
73
|
+
end
|
74
|
+
:menu_refresh
|
75
|
+
end
|
76
|
+
}
|
77
|
+
}
|
78
|
+
) do
|
79
|
+
if all_tests_selected
|
80
|
+
selected_tests_suites.delete(tests_suite)
|
81
|
+
else
|
82
|
+
selected_tests_suites[tests_suite] = Hash[statuses_for_suite.map { |(test_name, _test_status)| [test_name, nil] }]
|
83
|
+
end
|
84
|
+
:menu_refresh
|
85
|
+
end
|
86
|
+
end
|
87
|
+
test_menu.item 'Select tests that are not ok' do
|
88
|
+
selected_tests_suites = {}
|
89
|
+
tests_runner.tests_suites.map do |tests_suite|
|
90
|
+
tests_not_ok = {}
|
91
|
+
tests_runner.statuses_for(tests_suite).each do |(test_name, test_status)|
|
92
|
+
tests_not_ok[test_name] = nil unless test_status == 'ok'
|
93
|
+
end
|
94
|
+
selected_tests_suites[tests_suite] = tests_not_ok unless tests_not_ok.empty?
|
95
|
+
end
|
96
|
+
:menu_refresh
|
97
|
+
end
|
98
|
+
test_menu.item 'Register tests from selected test types' do
|
99
|
+
selected_tests_suites.keys.each do |tests_suite|
|
100
|
+
tests_runner.set_statuses_for(
|
101
|
+
tests_suite,
|
102
|
+
(
|
103
|
+
tests_runner.discover_tests_for(tests_suite) -
|
104
|
+
tests_runner.statuses_for(tests_suite).map { |(test_name, _test_status)| test_name }
|
105
|
+
).map { |test_name| [test_name, ''] }
|
106
|
+
)
|
107
|
+
end
|
108
|
+
:menu_refresh
|
109
|
+
end
|
110
|
+
test_menu.item 'Unregister tests from selected test types' do
|
111
|
+
selected_tests_suites.keys.each do |tests_suite|
|
112
|
+
tests_runner.clear_tests_for(tests_suite)
|
113
|
+
end
|
114
|
+
:menu_refresh
|
115
|
+
end
|
116
|
+
test_menu.item 'Clear selected test statuses' do
|
117
|
+
selected_tests_suites.each do |tests_suite, test_names_set|
|
118
|
+
tests_runner.set_statuses_for(tests_suite, test_names_set.keys.map { |test_name| [test_name, ''] })
|
119
|
+
end
|
120
|
+
:menu_refresh
|
121
|
+
end
|
122
|
+
test_menu.item 'Run remaining selected tests' do
|
123
|
+
tests_runner.run(
|
124
|
+
selected_tests_suites.map do |selected_tests_suite, selected_test_names_set|
|
125
|
+
[
|
126
|
+
selected_tests_suite,
|
127
|
+
# Make sure tests to be run are ordered from the registered list
|
128
|
+
tests_runner.
|
129
|
+
statuses_for(selected_tests_suite).map { |(test_name, _test_status)| test_name }.
|
130
|
+
select { |test_name| selected_test_names_set.key?(test_name) }
|
131
|
+
]
|
132
|
+
end
|
133
|
+
)
|
134
|
+
:menu_refresh
|
135
|
+
end
|
136
|
+
test_menu.item 'Back' do
|
137
|
+
:menu_exit
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
game.complete_game_menu(game_menu) if game.respond_to?(:complete_game_menu)
|
142
|
+
game_menu.item 'Back' do
|
143
|
+
:menu_exit
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
main_menu.item 'See logs' do
|
149
|
+
CursesMenu.new 'Modsvaskr - Stronghold of Mods > Logs' do |logs_menu|
|
150
|
+
File.read(Logger.log_file).split("\n").each do |line|
|
151
|
+
logs_menu.item line
|
152
|
+
end
|
153
|
+
logs_menu.item 'Back' do
|
154
|
+
:menu_exit
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
main_menu.item 'Quit' do
|
159
|
+
:menu_exit
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
end
|
166
|
+
|
167
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
require 'modsvaskr/run_cmd'
|
2
|
+
|
3
|
+
module Modsvaskr
|
4
|
+
|
5
|
+
# Helper to use an instance of xEdit
|
6
|
+
class Xedit
|
7
|
+
|
8
|
+
include RunCmd
|
9
|
+
|
10
|
+
# String: Installation path
|
11
|
+
attr_reader :install_path
|
12
|
+
|
13
|
+
# Constructor
|
14
|
+
#
|
15
|
+
# Parameters::
|
16
|
+
# * *install_path* (String): Installation path of xEdit
|
17
|
+
# * *game_path* (String): Installation path of the game to use xEdit on
|
18
|
+
def initialize(install_path, game_path)
|
19
|
+
@install_path = install_path
|
20
|
+
@game_path = game_path
|
21
|
+
# Set of scripts that have been run
|
22
|
+
@runs = {}
|
23
|
+
end
|
24
|
+
|
25
|
+
# Run an xEdit script
|
26
|
+
#
|
27
|
+
# Parameters::
|
28
|
+
# * *script* (String): Script name, as defined in xedit_scripts (without the Modsvaskr_ prefix and .pas suffix)
|
29
|
+
# * *only_once* (Boolean): If true, then make sure this script is run only once by instance [default: false]
|
30
|
+
def run_script(script, only_once: false)
|
31
|
+
if false
|
32
|
+
# if !only_once || !@runs.key?(script)
|
33
|
+
FileUtils.cp "#{__dir__}/../../xedit_scripts/Modsvaskr_#{script}.pas", "#{@install_path}/Edit Scripts/Modsvaskr_#{script}.pas"
|
34
|
+
run_cmd(
|
35
|
+
{
|
36
|
+
dir: @install_path,
|
37
|
+
exe: 'SSEEdit.exe'
|
38
|
+
},
|
39
|
+
args: %W[
|
40
|
+
-IKnowWhatImDoing
|
41
|
+
-AllowMasterFilesEdit
|
42
|
+
-SSE
|
43
|
+
-autoload
|
44
|
+
-script:"Modsvaskr_#{script}.pas"
|
45
|
+
]
|
46
|
+
)
|
47
|
+
@runs[script] = nil
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
metadata
ADDED
@@ -0,0 +1,87 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: modsvaskr
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Muriel Salvan
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2020-11-21 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: curses_menu
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: nokogiri
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.10'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.10'
|
41
|
+
description:
|
42
|
+
email:
|
43
|
+
- muriel@x-aeon.com
|
44
|
+
executables:
|
45
|
+
- modsvaskr
|
46
|
+
extensions: []
|
47
|
+
extra_rdoc_files: []
|
48
|
+
files:
|
49
|
+
- bin/modsvaskr
|
50
|
+
- lib/modsvaskr/config.rb
|
51
|
+
- lib/modsvaskr/game.rb
|
52
|
+
- lib/modsvaskr/games/skyrim_se.rb
|
53
|
+
- lib/modsvaskr/logger.rb
|
54
|
+
- lib/modsvaskr/run_cmd.rb
|
55
|
+
- lib/modsvaskr/tests_runner.rb
|
56
|
+
- lib/modsvaskr/tests_suite.rb
|
57
|
+
- lib/modsvaskr/tests_suites/exterior_cell.rb
|
58
|
+
- lib/modsvaskr/tests_suites/interior_cell.rb
|
59
|
+
- lib/modsvaskr/tests_suites/npc.rb
|
60
|
+
- lib/modsvaskr/ui.rb
|
61
|
+
- lib/modsvaskr/version.rb
|
62
|
+
- lib/modsvaskr/xedit.rb
|
63
|
+
homepage: http://x-aeon.com
|
64
|
+
licenses:
|
65
|
+
- BSD-3-Clause
|
66
|
+
metadata:
|
67
|
+
homepage_uri: http://x-aeon.com
|
68
|
+
post_install_message:
|
69
|
+
rdoc_options: []
|
70
|
+
require_paths:
|
71
|
+
- lib
|
72
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
73
|
+
requirements:
|
74
|
+
- - ">="
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: '0'
|
77
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - ">="
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: '0'
|
82
|
+
requirements: []
|
83
|
+
rubygems_version: 3.0.3
|
84
|
+
signing_key:
|
85
|
+
specification_version: 4
|
86
|
+
summary: 'Stronghold for mods acting like companions: The Modsvaskr'
|
87
|
+
test_files: []
|