diff --git a/.idea/plugin/PluginManager.md b/.idea/plugin/PluginManager.md new file mode 100644 index 0000000..37de7c2 --- /dev/null +++ b/.idea/plugin/PluginManager.md @@ -0,0 +1,65 @@ +## Story + +Source: [Github Issue #10](https://github.com/PokemonWorkshop/psdk-cli/issues/10) + +### Description + +As a developer, I want to use `psdk-plugin` to manage my PSDK plugins (create, test, update) to simplify maintenance. + +> [!NOTE] +> We'll remove the plugin command from PSDK (aside the one for loading the plugin) and psdk cli will be responsible of plugin management (including build). +> It'll accept both workflow: +> - Plugin inside a PSDK project (meaning the resources are expected to be at the root of the project and scripts at the root of the plugin) +> - Plugin outside a PSDK project (meaning both resources and scripts are expected to be at the root of the plugin) +> The definition file remain the same for both workflow + +### Acceptance criteria + +- [ ] `psdk-plugin` exposes the same arguments as the original plugin manager. +- [ ] The command detects the project or switches to standalone mode. +- [ ] A clear message lists available actions (list, build). + +## Information about `PluginManager.rb` + +This file depends on Yuki::VD (defined in `lib/psdk/helpers/900 Yuki__VD.rb`) + +This file was taken out of Pokémon SDK and currently is only working in the context of a PSDK project. + +In the psdk-cli I want the following commands: + +- `PluginManager.start(:build, 'plugin_name')` +- `PluginManager.start(:list)` + +The `PluginManager.start(:load)` should never work in the psdk-cli as it's supposed to run in a game not in the CLI. + +Currently, the build process expects the following structure: + +- `scripts/{plugin_name}/config.yml` (plugin configuration file) +- `scripts/{plugin_name}/script/**/*.rb` (scripts, automatic) +- `graphics/**/*.*` (added_file) +- `Data/**/*.*` (added_files) +- `audio/**/*.*` (added_files) + +I want this process to be preserved if psdk-cli is running in a PSDK project (`Configuration.project_path != nil`). And if I'm not in a project (or a --no-psdk-project flag is set). I want the following structure so developing plugins is easier: + +- `config.yml` (plugin configuration file) +- `scripts/**/*.rb` (scripts, automatic) +- `graphics/**/*.*` (added_file) +- `Data/**/*.*` (added_files) +- `audio/**/*.*` (added_files) + +In this structure, it's assumed that we are inside `{plugin_name}` (if we use `.` as plugin directory we should skip the plugin_name argument of `PluginManager.start(:build)` since the name is also inside `config.yml`) + +## Task to perform + +1. Explode `PluginManager.rb` in several ruby scripts that are able to perform the following operations: + - `list` all the plugins of current project (should fail if not in a project) + - `build` a plugin (name is optional, if not provided and not in a project, it's `.`) +2. Rewrite the Build command so it's + 1. Verbose (explaining each stuff it does and where it is) + 2. Able to work outside of a psdk project (should also state it's not in a PSDK project) +3. Move the exploded PluginManager files to `lib/psdk/helpers/plugin-manager` +4. Write a script in `lib/psdk/cli` to expose the `psdk-cli plugin` cli commands +5. Write a `psdk-plugin` file in `exe` (so I can run `psdk-plugin` instead of `psdk-cli plugin`) +6. Write unit tests in `spec` for Exploded PluginManager files +7. Write unit tests in `spec` for the `psdk-cli plugin` commands diff --git a/Gemfile.lock b/Gemfile.lock index 39616f2..b37d2fb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - psdk-cli (0.1.0) + psdk-cli (0.1.1) GEM remote: https://rubygems.org/ diff --git a/lib/psdk/cli/plugin.rb b/lib/psdk/cli/plugin.rb index cade952..41cf540 100644 --- a/lib/psdk/cli/plugin.rb +++ b/lib/psdk/cli/plugin.rb @@ -8,10 +8,39 @@ module Cli class Plugin < Thor package_name 'psdk-plugin' - # TODO: remove this and actually implement the plugin cli - desc 'fake ARG1', 'run a fake command with ARG1 as first argument' - def fake(arg1) - puts "Fake called with ARG1=#{arg1}" + desc 'list', 'List all installed plugins in the current PSDK project' + def list + require_relative 'configuration' + require_relative '../helpers/plugin_manager' + Psdk::Cli::Configuration.get(:local) + + unless Psdk::Cli::Configuration.project_path + puts 'Error: You must be inside a PSDK project to use `psdk-plugin list`.' + exit(1) + end + + Psdk::Helpers::PluginManager.list + end + + desc 'build [PLUGIN_NAME]', 'Build a plugin (requires to be at the root of the project or the plugin)' + option :no_psdk_project, type: :boolean, desc: 'Force standalone mode (build outside a PSDK project)' + option :out_dir, type: :string, default: '.', desc: 'Output directory for the generated .psdkplug file' + def build(plugin_name = nil) # rubocop:disable Metrics/MethodLength + require_relative 'configuration' + require_relative '../helpers/plugin_manager' + Psdk::Cli::Configuration.get(:local) + + in_project = Psdk::Cli::Configuration.project_path + in_project = false if options[:no_psdk_project] + + plugin_name = '.' if !in_project && plugin_name.nil? + + unless plugin_name + puts 'Error: You must provide a plugin_name when building inside a PSDK project.' + exit(1) + end + + Psdk::Helpers::PluginManager.build(plugin_name, in_project: in_project, out_dir: options[:out_dir]) end end end diff --git a/lib/psdk/cli/version.rb b/lib/psdk/cli/version.rb index 16ac99e..8dc989b 100644 --- a/lib/psdk/cli/version.rb +++ b/lib/psdk/cli/version.rb @@ -2,6 +2,6 @@ module Psdk module Cli - VERSION = '0.1.0' + VERSION = '0.1.1' end end diff --git a/lib/psdk/helpers/900 Yuki__VD.rb b/lib/psdk/helpers/900 Yuki__VD.rb new file mode 100644 index 0000000..b1a3b91 --- /dev/null +++ b/lib/psdk/helpers/900 Yuki__VD.rb @@ -0,0 +1,147 @@ +# rubocop:disable all +module Yuki + # Class that helps to read Virtual Directories + # + # In reading mode, the Virtual Directories can be loaded to RAM if MAX_SIZE >= VD.size + # + # All the filenames inside the Yuki::VD has to be downcased filename in utf-8 + # + # Note : Encryption is up to the developper and no longer supported on the basic script + class VD + # @return [String] the filename of the current Yuki::VD + attr_reader :filename + + # Is the debug info on ? + DEBUG_ON = ARGV.include?('debug-yuki-vd') + # The max size of the file that can be loaded in memory + MAX_SIZE = 10 * 1024 * 1024 # 10Mo + # List of allowed modes + ALLOWED_MODES = %i[read write update] + # Size of the pointer at the begin of the file + POINTER_SIZE = 4 + # Unpack method of the pointer at the begin of the file + UNPACK_METHOD = 'L' + # Create a new Yuki::VD file or load it + # @param filename [String] name of the Yuki::VD file + # @param mode [:read, :write, :update] if we read or write the virtual directory + def initialize(filename, mode) + @mode = mode = fix_mode(mode) + @filename = filename + send("initialize_#{mode}") + end + + # Read a file data from the VD + # @param filename [String] the file we want to read its data + # @return [String, nil] the data of the file + def read_data(filename) + return nil unless @file + + pos = @hash[filename] + return nil unless pos + + @file.pos = pos + size = @file.read(POINTER_SIZE).unpack1(UNPACK_METHOD) + return @file.read(size) + end + + # Test if a file exists in the VD + # @param filename [String] + # @return [Boolean] + def exists?(filename) + @hash[filename] != nil + end + + # Write a file with its data in the VD + # @param filename [String] the file name + # @param data [String] the data of the file + def write_data(filename, data) + return unless @file + + @hash[filename] = @file.pos + @file.write([data.bytesize].pack(UNPACK_METHOD)) + @file.write(data) + end + + # Add a file to the Yuki::VD + # @param filename [String] the file name + # @param ext_name [String, nil] the file extension + def add_file(filename, ext_name = nil) + sub_filename = ext_name ? "#{filename}.#{ext_name}" : filename + write_data(filename, File.binread(sub_filename)) + end + + # Get all the filename + # @return [Array] + def get_filenames + @hash.keys + end + + # Close the VD + def close + return unless @file + + if @mode != :read + pos = [@file.pos].pack(UNPACK_METHOD) + @file.write(Marshal.dump(@hash)) + @file.pos = 0 + @file.write(pos) + end + @file.close + @file = nil + end + + private + + # Initialize the Yuki::VD in read mode + def initialize_read + @file = File.new(filename, 'rb') + pos = @file.pos = @file.read(POINTER_SIZE).unpack1(UNPACK_METHOD) + @hash = Marshal.load(@file) + load_whole_file(pos) if pos < MAX_SIZE + rescue Errno::ENOENT + @file = nil + @hash = {} + log_error(format('%s not found', filename: filename)) if DEBUG_ON + end + + # Load the VD in the memory + # @param size [Integer] size of the VD memory + def load_whole_file(size) + @file.pos = 0 + data = @file.read(size) + @file.close + @file = StringIO.new(data, 'rb+') + @file.pos = 0 + end + + # Initialize the Yuki::VD in write mode + def initialize_write + @file = File.new(filename, 'wb') + @file.pos = POINTER_SIZE + @hash = {} + end + + # Initialize the Yuki::VD in update mode + def initialize_update + @file = File.new(filename, 'rb+') + pos = @file.pos = @file.read(POINTER_SIZE).unpack1(UNPACK_METHOD) + @hash = Marshal.load(@file) + @file.pos = pos + end + + # Fix the input mode in case it's a String + # @param mode [Symbol, String] + # @return [Symbol] one of the value of ALLOWED_MODES + def fix_mode(mode) + return mode if ALLOWED_MODES.include?(mode) + + r = (mode = mode.downcase).include?('r') + w = mode.include?('w') + plus = mode.include?('+') + return :update if plus || (r && w) + return :read if r + + return :write + end + end +end diff --git a/lib/psdk/helpers/plugin_manager.rb b/lib/psdk/helpers/plugin_manager.rb new file mode 100644 index 0000000..950f327 --- /dev/null +++ b/lib/psdk/helpers/plugin_manager.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative 'plugin_manager/config' +require_relative 'plugin_manager/list' +require_relative 'plugin_manager/builder' + +module Psdk + module Helpers + # Module handling the plugin commands + module PluginManager + class << self + # List all the plugins installed in the current PSDK project + def list + List.list_plugins + end + + # Build a plugin + # @param plugin_name [String] name of the plugin to build + # @param in_project [Boolean] whether to build using PSDK project structure + # @param out_dir [String] directory to output the compiled plugin + def build(plugin_name, in_project: true, out_dir: '.') + Builder.new(plugin_name, in_project: in_project, out_dir: out_dir).build + end + end + end + end +end diff --git a/lib/psdk/helpers/plugin_manager/builder.rb b/lib/psdk/helpers/plugin_manager/builder.rb new file mode 100644 index 0000000..cacb3bb --- /dev/null +++ b/lib/psdk/helpers/plugin_manager/builder.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require 'yaml' +require 'digest' +require_relative '../900 Yuki__VD' + +module Psdk + module Helpers + module PluginManager + # Class responsible of building plugins + class Builder # rubocop:disable Metrics/ClassLength + PLUGIN_FILE_EXT = 'psdkplug' + SCRIPTS_FOLDER = 'scripts' + + # Create a new plugin builder + # @param plugin_name [String] name of the plugin directory + # @param in_project [Boolean] whether we are building inside a PSDK project + # @param out_dir [String] directory to output the compiled plugin + def initialize(plugin_name, in_project: true, out_dir: '.') + @name = plugin_name + @in_project = in_project + @out_dir = out_dir + end + + # Start the building process + def build # rubocop:disable Metrics/MethodLength + puts "--- Starting build for plugin '#{@name}' ---" + if @in_project + puts '[INFO] Operating inside a PSDK project.' + else + puts '[INFO] Operating in standalone mode (outside a PSDK project).' + end + + project_root = @in_project.is_a?(String) ? @in_project : Dir.pwd + out_dir = File.absolute_path(@out_dir) + + Dir.chdir(project_root) do + @config = load_plugin_configuration + plugin_filename = File.join(out_dir, "#{@config.name}.#{PLUGIN_FILE_EXT}") + tmp_filename = "#{plugin_filename}.tmp" + + build_internal(tmp_filename) + compute_sha512(tmp_filename) + write_config_an_rename_file(tmp_filename, plugin_filename) + + puts "\e[32m[SUCCESS]\e[0m Built #{@config.name} at #{plugin_filename}" + end + end + + private + + # Return the base directory of the plugin source code + def base_dir + if @in_project + File.join(SCRIPTS_FOLDER, @name) + else + @name == '.' ? '.' : @name + end + end + + # Return the directory name used for scripts relative to base_dir + def script_src_dir + # Inside a project, scripts are often in `scripts/{plugin_name}/scripts/**/*.rb` + # Outside, it's `scripts/**/*.rb` + # In both cases, the folder is `scripts` relative to base_dir (or `script` according to some docs, we check both or use 'scripts') # rubocop:disable Layout/LineLength + return 'scripts' + end + + # Internal plugin build process + # @param tmp_filename [String] temporary filename of the .psdkplug file (before SHA512 computation) + def build_internal(tmp_filename) + puts "Creating temporary plugin file: #{tmp_filename}" + @yuki_vd = Yuki::VD.new(tmp_filename, :write) + + add_scripts + add_files + add_testers + + @yuki_vd.close + end + + # Function that adds all the scripts for the plugin + def add_scripts # rubocop:disable Metrics/MethodLength + b_dir = base_dir + b_dir_prefix = b_dir == '.' ? '' : "#{b_dir}/" + + search_path = File.join(b_dir, script_src_dir, '**', '*.rb') + search_path = search_path.sub(%r{^/}, '') if search_path.start_with?('/') + + scripts = Dir[search_path] + + puts "Found #{scripts.size} ruby scripts to pack." + scripts.each do |filename| + script = File.read(filename) + internal_path = filename.sub(b_dir_prefix, '') + puts " - Packing script: #{filename} -> #{internal_path}" + @yuki_vd.write_data(internal_path, script) + end + end + + # Function that add all the files for the plugin + def add_files + filenames = (@config.added_files || []).flat_map { |dirspec| Dir[dirspec] }.select { |f| File.file?(f) } + + puts "Found #{filenames.size} resource files to pack." + filenames.each do |filename| + data = File.binread(filename) + puts " - Packing file: #{filename}" + @yuki_vd.write_data(filename, data) + end + end + + # Function that adds the compatibility test script + def add_testers # rubocop:disable Metrics/AbcSize,Metrics/MethodLength + b_dir = base_dir + if @config.psdk_compatibility_script + tester_path = File.join(b_dir, @config.psdk_compatibility_script) + if File.exist?(tester_path) + puts "Adding PSDK compatibility script: #{tester_path}" + data = File.read(tester_path) + @yuki_vd.write_data("\x01", data) + else + puts "[WARNING] PSDK compatibility script not found: #{tester_path}" + end + end + return unless @config.additional_compatibility_script + + tester_path = File.join(b_dir, @config.additional_compatibility_script) + if File.exist?(tester_path) + puts "Adding additional compatibility script: #{tester_path}" + data = File.read(tester_path) + @yuki_vd.write_data("\x02", data) + else + puts "[WARNING] Additional compatibility script not found: #{tester_path}" + end + end + + # Load the plugin configuration + # @return [Psdk::Helpers::PluginManager::Config] + def load_plugin_configuration + config_path = File.join(base_dir, 'config.yml') + raise "Configuration file not found at #{config_path}" unless File.exist?(config_path) + + puts "Loading configuration from #{config_path}" + return YAML.unsafe_load(File.read(config_path)) + end + + # Compute the SHA512 hash of the temporary psdkplug + # @param tmp_filename [String] filename of the temporary psdkplug + def compute_sha512(tmp_filename) + puts 'Computing SHA512 of the generated package...' + filesize = File.binread(tmp_filename, Yuki::VD::POINTER_SIZE).unpack1(Yuki::VD::UNPACK_METHOD) - Yuki::VD::POINTER_SIZE + filedata = File.binread(tmp_filename, filesize, Yuki::VD::POINTER_SIZE) + @config.sha512 = Digest::SHA512.hexdigest(filedata) + end + + # Write the config of the plugin and rename the temporary file to the final plugin filename + # @param tmp_filename [String] filename of the temporary psdkplug + # @param plugin_filename [String] filename of the final psdkplug + def write_config_an_rename_file(tmp_filename, plugin_filename) + puts 'Writing final configuration with SHA512 to package...' + @yuki_vd = Yuki::VD.new(tmp_filename, :update) + @yuki_vd.write_data("\x00", Marshal.dump(@config)) + @yuki_vd.close + + File.rename(tmp_filename, plugin_filename) + end + end + end + end +end diff --git a/lib/psdk/helpers/plugin_manager/config.rb b/lib/psdk/helpers/plugin_manager/config.rb new file mode 100644 index 0000000..08b89d8 --- /dev/null +++ b/lib/psdk/helpers/plugin_manager/config.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Psdk + module Helpers + module PluginManager + # Plugin configuration + class Config + # Get the plugin name + # @return [String] + attr_accessor :name + # Get the plugin authors + # @return [Array] + attr_accessor :authors + # Get the version of the plugin + # @return [String] + attr_accessor :version + # Get the dependecies or incompatibilities of the plugin + # @return [Array] + attr_accessor :deps + # Get the script that tests if PSDK is compatible with this plugin + # @return [String, nil] + attr_accessor :psdk_compatibility_script + # Tell if the psdk_compatibility_script should be executed after all plugins has been loaded + # @return [Boolean, nil] + attr_accessor :retry_psdk_compatibility_after_plugin_load + # Get the script that tests if the plugin is compatible with other plugins + # @return [String, nil] + attr_accessor :additional_compatibility_script + # Get all the files added by the plugin (in order to compile the plugin / remove files) + # @return [Array] + attr_accessor :added_files + # Get the SHA512 of the plugin (computed after it got compiled) + # @return [String] + attr_accessor :sha512 + # Get the PSDK version the plugin was installed + # @return [Integer] + attr_accessor :psdk_version + end + end + end +end + +PluginManager = Psdk::Helpers::PluginManager diff --git a/lib/psdk/helpers/plugin_manager/list.rb b/lib/psdk/helpers/plugin_manager/list.rb new file mode 100644 index 0000000..dff285d --- /dev/null +++ b/lib/psdk/helpers/plugin_manager/list.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Psdk + module Helpers + module PluginManager + # Module handling the listing of plugins + module List + # Folder containing scripts + SCRIPTS_FOLDER = 'scripts' + # File containing plugin information + PLUGIN_INFO_FILE = "#{SCRIPTS_FOLDER}/plugins.dat".freeze + + class << self + # List all the plugins + def list_plugins + plugins = load_existing_plugins + show_splash(' List of your plugins') + if plugins.empty? + puts 'No plugins installed.' + return + end + + plugins.each do |plugin| + puts "- \e[34m#{plugin.name}\e[36m v#{plugin.version}\e[0m" + puts " authors: #{plugin.authors.join(', ')}" + end + end + + private + + # Load the plugins that are already installed + # @return [Array] + def load_existing_plugins + return File.exist?(PLUGIN_INFO_FILE) ? Marshal.load(File.binread(PLUGIN_INFO_FILE)) : [] # rubocop:disable Security/MarshalLoad + rescue StandardError => e + puts "Failed to load plugins.dat: #{e.message}" + [] + end + + # Show the plugin manager splash + # @param reason [String] reason to show the splash + def show_splash(reason = ' Something changed in your plugins! ') + sep = ''.center(80, '=') + puts "\e[32m#{sep}\e[0m" + puts "\e[32m##{' PSDK Plugin Manager v1.0 '.center(78, ' ')}#\e[0m" + puts "\e[32m##{reason.ljust(78, ' ')}#\e[0m" + puts "\e[32m#{sep}\e[0m" + end + end + end + end + end +end diff --git a/spec/psdk/cli/plugin_spec.rb b/spec/psdk/cli/plugin_spec.rb new file mode 100644 index 0000000..9e388db --- /dev/null +++ b/spec/psdk/cli/plugin_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'psdk/cli/plugin' +require 'psdk/cli/configuration' + +RSpec.describe Psdk::Cli::Plugin do # rubocop:disable Metrics/BlockLength + subject { described_class.new } + + describe '#list' do + context 'when in a PSDK project' do + let(:config_mock) { double('Configuration', project_path: '/path/to/project') } + + before do + allow(Psdk::Cli::Configuration).to receive(:get).with(:local).and_return(config_mock) + allow(Psdk::Helpers::PluginManager).to receive(:list) + end + + it 'calls Psdk::Helpers::PluginManager.list' do + expect(Psdk::Helpers::PluginManager).to receive(:list) + subject.list + end + end + + context 'when not in a PSDK project' do + before do + allow(Psdk::Cli::Configuration).to receive(:get).with(:local) + allow(Psdk::Cli::Configuration).to receive(:project_path).and_return(nil) + end + + it 'exits with 1 and prints an error' do + expect($stdout).to receive(:puts).with(/You must be inside a PSDK project/) + expect { subject.list }.to raise_error(SystemExit) { |e| expect(e.status).to eq(1) } + end + end + end + + describe '#build' do # rubocop:disable Metrics/BlockLength + before do + allow(Psdk::Cli::Configuration).to receive(:get).with(:local) + allow(Psdk::Helpers::PluginManager).to receive(:build) + subject.options = { out_dir: '.' } + end + + context 'when in a PSDK project' do + before do + allow(Psdk::Cli::Configuration).to receive(:project_path).and_return('/path/to/project') + end + + it 'calls Psdk::Helpers::PluginManager.build with plugin name' do + expect(Psdk::Helpers::PluginManager).to( + receive(:build).with('my_plugin', in_project: '/path/to/project', out_dir: '.') + ) + subject.build('my_plugin') + end + + it 'exits with 1 if no plugin_name is provided' do + expect($stdout).to receive(:puts).with(/You must provide a plugin_name/) + expect { subject.build(nil) }.to raise_error(SystemExit) { |e| expect(e.status).to eq(1) } + end + end + + context 'when in standalone mode' do + before do + allow(Psdk::Cli::Configuration).to receive(:project_path).and_return(nil) + end + + it 'calls Psdk::Helpers::PluginManager.build with default name' do + expect(Psdk::Helpers::PluginManager).to receive(:build).with('.', in_project: nil, out_dir: '.') + subject.build(nil) + end + + it 'calls Psdk::Helpers::PluginManager.build with provided name' do + expect(Psdk::Helpers::PluginManager).to receive(:build).with('my_plugin', in_project: nil, out_dir: '.') + subject.build('my_plugin') + end + end + + context 'when forcing standalone mode with flag' do + before do + subject.options = { no_psdk_project: true, out_dir: '.' } + end + + it 'builds with in_project: false despite being in a project' do + expect(Psdk::Helpers::PluginManager).to receive(:build).with('my_plugin', in_project: false, out_dir: '.') + subject.build('my_plugin') + end + end + end +end diff --git a/spec/psdk/helpers/plugin_manager_spec.rb b/spec/psdk/helpers/plugin_manager_spec.rb new file mode 100644 index 0000000..e559517 --- /dev/null +++ b/spec/psdk/helpers/plugin_manager_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'psdk/helpers/plugin_manager' + +RSpec.describe Psdk::Helpers::PluginManager do + describe '.list' do + it 'calls List.list_plugins' do + expect(Psdk::Helpers::PluginManager::List).to receive(:list_plugins) + described_class.list + end + end + + describe '.build' do + it 'instantiates Builder and calls build' do + builder_mock = instance_double(Psdk::Helpers::PluginManager::Builder) + expect(Psdk::Helpers::PluginManager::Builder).to( + receive(:new).with('my_plugin', in_project: '/path/to/project', out_dir: '.').and_return(builder_mock) + ) + expect(builder_mock).to receive(:build) + + described_class.build('my_plugin', in_project: '/path/to/project', out_dir: '.') + end + end +end + +RSpec.describe Psdk::Helpers::PluginManager::List do + describe '.list_plugins' do + before do + allow(File).to receive(:exist?).and_return(true) + allow(File).to receive(:binread).and_return('mocked_data') + + plugin_mock = Psdk::Helpers::PluginManager::Config.new + plugin_mock.name = 'TestPlugin' + plugin_mock.version = '1.0' + plugin_mock.authors = ['Test Author'] + + allow(Marshal).to receive(:load).and_return([plugin_mock]) + allow($stdout).to receive(:puts) + end + + it 'prints the plugins' do + expect($stdout).to receive(:puts).with(/- \e\[34mTestPlugin\e\[36m v1.0\e\[0m/) + described_class.list_plugins + end + end +end + +RSpec.describe Psdk::Helpers::PluginManager::Builder do # rubocop:disable Metrics/BlockLength + let(:plugin_name) { 'my_plugin' } + subject { described_class.new(plugin_name, in_project: in_project, out_dir: '.') } + + describe '#build' do # rubocop:disable Metrics/BlockLength + let(:yuki_vd_mock) { instance_double(Yuki::VD) } + let(:config_obj) do + config = Psdk::Helpers::PluginManager::Config.new + config.name = 'test_plugin' + config.version = '1.0' + config.added_files = ['*.png'] + config + end + + before do + allow($stdout).to receive(:puts) + allow(Yuki::VD).to receive(:new).and_return(yuki_vd_mock) + allow(yuki_vd_mock).to receive(:write_data) + allow(yuki_vd_mock).to receive(:close) + + allow(File).to( + receive(:read).with(/config\.yml/).and_return("name: test_plugin\nversion: 1.0\nadded_files:\n - '*'\n") + ) + allow(YAML).to receive(:unsafe_load).and_return(config_obj) + allow(File).to receive(:exist?).and_return(true) + allow(File).to receive(:binread).and_return("\x00" * 32) + allow(File).to receive(:rename) + allow(Dir).to receive(:chdir).and_yield + end + + context 'when in a PSDK project' do + let(:in_project) { '/path/to/project' } + + it 'uses project script paths' do + expect(subject).to receive(:add_scripts).and_call_original + expect(Dir).to receive(:[]).with('scripts/my_plugin/scripts/**/*.rb').and_return([]) + expect(Dir).to receive(:[]).with('*.png').and_return([]) # added_files is empty + + subject.build + end + end + + context 'when in standalone mode' do + let(:in_project) { false } + let(:plugin_name) { '.' } + + it 'uses standalone script paths' do + expect(subject).to receive(:add_scripts).and_call_original + expect(Dir).to receive(:[]).with('./scripts/**/*.rb').and_return([]) + expect(Dir).to receive(:[]).with('*.png').and_return([]) # added_files is empty + + subject.build + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 54faba4..1ae9bd3 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -4,6 +4,7 @@ require 'psdk/cli/configuration' require 'psdk/helpers/studio' require 'psdk/helpers/version' +require 'psdk/helpers/900 Yuki__VD' RSpec.configure do |config| # Enable flags like --only-failures and --next-failure diff --git a/spec/yuki_vd_spec.rb b/spec/yuki_vd_spec.rb new file mode 100644 index 0000000..d1e5525 --- /dev/null +++ b/spec/yuki_vd_spec.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require_relative '../lib/psdk/helpers/900 Yuki__VD' + +RSpec.describe Yuki::VD do # rubocop:disable Metrics/BlockLength + let(:vd_filename) { 'test.vd' } + let(:file_double) { instance_double(File) } + let(:string_io_double) { instance_double(StringIO) } + let(:pointer_size) { 4 } + # MAX_SIZE is 10MB + let(:max_size) { 10 * 1024 * 1024 } + + before do + # Mock File class methods + allow(File).to receive(:new).and_return(file_double) + allow(File).to receive(:binread) + allow(File).to receive(:exist?).and_return(false) + allow(File).to receive(:delete) + allow(StringIO).to receive(:new).and_return(string_io_double) + + # Mock File instance methods + allow(file_double).to receive(:pos=) + allow(file_double).to receive(:pos).and_return(0) + allow(file_double).to receive(:read) + allow(file_double).to receive(:write) + allow(file_double).to receive(:close) + allow(string_io_double).to receive(:pos=) + allow(string_io_double).to receive(:pos).and_return(0) + allow(string_io_double).to receive(:read) + allow(string_io_double).to receive(:write) + allow(string_io_double).to receive(:close) + + # Mock Marshal since it interacts with the file + allow(Marshal).to receive(:dump).and_return('marshaled_data') + allow(Marshal).to receive(:load).and_return({}) + end + + context 'when writing a new VD' do # rubocop:disable Metrics/BlockLength + it 'creates a file with write mode' do + Yuki::VD.new(vd_filename, :write) + expect(File).to have_received(:new).with(vd_filename, 'wb') + end + + it 'writes data correctly' do + vd = Yuki::VD.new(vd_filename, :write) + filename = 'test.txt' + data = 'content' + + # Mock pos to return a specific offset for the file + allow(file_double).to receive(:pos).and_return(123) + + vd.write_data(filename, data) + + expect(file_double).to have_received(:write).with([data.bytesize].pack('L')) + expect(file_double).to have_received(:write).with(data) + end + + it 'writes header and hash on close' do + vd = Yuki::VD.new(vd_filename, :write) + filename = 'test.txt' + data = 'content' + + # Position of the file when attempting to write data + allow(file_double).to receive(:pos).and_return(250) + vd.write_data(filename, data) + + # Position of the file when closing + allow(file_double).to receive(:pos).and_return(500) + vd.close + + expect(Marshal).to have_received(:dump).with({ filename => 250 }) + expect(file_double).to have_received(:write).with('marshaled_data') + expect(file_double).to have_received(:pos=).with(0) + expect(file_double).to have_received(:write).with([500].pack('L')) + expect(file_double).to have_received(:close) + end + + it 'adds a file using File.binread' do + vd = Yuki::VD.new(vd_filename, :write) + allow(File).to receive(:binread).with('external.txt').and_return('external content') + + vd.add_file('external.txt') + + expect(File).to have_received(:binread).with('external.txt') + expect(file_double).to have_received(:write).with('external content') + end + end + + context 'when reading an existing VD' do # rubocop:disable Metrics/BlockLength + let(:fake_pointer) { 100 } + + before do + # Setup for read mode: read pointer, then Marshal.load + allow(file_double).to receive(:read).with(pointer_size).and_return([fake_pointer].pack('L')) + allow(Marshal).to receive(:load).and_return({ 'test.txt' => 50 }) + end + + it 'opens the file in read mode' do + # We need to ensure load_whole_file is NOT called for this test to keep using file_double + # If fake_pointer < MAX_SIZE, it loads whole file. + # Let's set fake_pointer to MAX_SIZE + 1 to avoid loading into memory for this test + allow(file_double).to receive(:read).with(pointer_size).and_return([max_size + 1].pack('L')) + + Yuki::VD.new(vd_filename, :read) + expect(File).to have_received(:new).with(vd_filename, 'rb') + end + + it 'reads data from the file' do + content = 'file content' + size = content.bytesize + + # Avoid load_whole_file + allow(file_double).to receive(:read).with(pointer_size).and_return( + [max_size + 1].pack('L'), # First call in initialize + [size].pack('L') # Second call in read_data (size) + ) + + # Ensure read(size) returns content + allow(file_double).to receive(:read).with(size).and_return(content) + + vd = Yuki::VD.new(vd_filename, :read) + result = vd.read_data('test.txt') + + expect(file_double).to have_received(:pos=).with(50) + expect(result).to eq(content) + end + + it 'loads into memory (StringIO) if file is small' do + # fake_pointer is 100, which is < MAX_SIZE + # It will call load_whole_file(100) + allow(file_double).to receive(:read).with(pointer_size).and_return([100].pack('L')) + allow(file_double).to receive(:read).with(100).and_return('whole file content') + + vd = Yuki::VD.new(vd_filename, :read) + + # Verify it read the whole file and closed the original file handle + expect(file_double).to have_received(:read).with(100) + expect(file_double).to have_received(:close) + + # Verify @file is now a StringIO + expect(vd.instance_variable_get(:@file)).to eq(string_io_double) + end + end + + context 'when updating a VD' do + let(:fake_pointer) { 200 } + + before do + allow(file_double).to receive(:read).with(pointer_size).and_return([fake_pointer].pack('L')) + allow(Marshal).to receive(:load).and_return({}) + end + + it 'opens the file in update mode' do + Yuki::VD.new(vd_filename, :update) + expect(File).to have_received(:new).with(vd_filename, 'rb+') + end + + it 'restores position after loading hash' do + Yuki::VD.new(vd_filename, :update) + # pos= is called twice: once to store return value of read, once to restore position + expect(file_double).to have_received(:pos=).with(fake_pointer).at_least(:once) + end + + it 'writes new data and updates hash on close' do + vd = Yuki::VD.new(vd_filename, :update) + + # Simulate adding a file + allow(file_double).to receive(:pos).and_return(300) + vd.write_data('new.txt', 'data') + + vd.close + + # Should write updated hash and new pointer + expect(Marshal).to have_received(:dump).with({ 'new.txt' => 300 }) + expect(file_double).to have_received(:write).with([300].pack('L')) + end + end +end