From fcf4b0a72489597878f9279f4e7c2e633c548584 Mon Sep 17 00:00:00 2001 From: Chayut Orapinpatipat Date: Mon, 27 Oct 2025 09:40:11 +1100 Subject: [PATCH 1/2] pass1 --- .todo/scheduled_control_fix.json | 14 ++ README.md | 45 +++- lib/novacloud_client/client.rb | 10 + .../objects/solutions/publish_result.rb | 34 +++ .../resources/concerns/payload_serializer.rb | 46 ++++ lib/novacloud_client/resources/control.rb | 53 +++++ lib/novacloud_client/resources/players.rb | 25 ++ .../resources/scheduled_control.rb | 213 ++++++++++++++++++ lib/novacloud_client/resources/solutions.rb | 77 +++++++ lib/novacloud_client/support/key_transform.rb | 32 +++ .../resources/control_spec.rb | 44 ++++ .../resources/players_spec.rb | 80 +++++++ .../resources/scheduled_control_spec.rb | 182 +++++++++++++++ .../resources/solutions_spec.rb | 156 +++++++++++++ 14 files changed, 1009 insertions(+), 2 deletions(-) create mode 100644 .todo/scheduled_control_fix.json create mode 100644 lib/novacloud_client/objects/solutions/publish_result.rb create mode 100644 lib/novacloud_client/resources/concerns/payload_serializer.rb create mode 100644 lib/novacloud_client/resources/scheduled_control.rb create mode 100644 lib/novacloud_client/resources/solutions.rb create mode 100644 lib/novacloud_client/support/key_transform.rb create mode 100644 spec/novacloud_client/resources/scheduled_control_spec.rb create mode 100644 spec/novacloud_client/resources/solutions_spec.rb diff --git a/.todo/scheduled_control_fix.json b/.todo/scheduled_control_fix.json new file mode 100644 index 0000000..9e2a355 --- /dev/null +++ b/.todo/scheduled_control_fix.json @@ -0,0 +1,14 @@ +{ + "tasks": [ + { + "id": 1, + "title": "Ensure week_days omitted when empty for scheduled control payloads", + "status": "in_progress" + }, + { + "id": 2, + "title": "Align video_source schedule payload validation with spec expectations", + "status": "in_progress" + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 1450c5d..ee50063 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,14 @@ - Maps HTTP errors to a typed exception hierarchy. - Normalizes GET/POST payloads and parses JSON responses. -Sprint 02 expands on this foundation with dedicated resource helpers (`client.players`, `client.control`) and typed response objects (e.g., `NovacloudClient::Objects::Player`). + Sprint 02 expands on this foundation with dedicated resource helpers (`client.players`, `client.control`, `client.scheduled_control`, `client.solutions`) and typed response objects (e.g., `NovacloudClient::Objects::Player`). ## Resource Overview - **Players**: `list`, `statuses`, `running_status` -- **Control**: `brightness`, `volume`, `video_source`, `screen_power`, `screen_status`, `screenshot`, `reboot`, `request_result` +- **Control**: `brightness`, `volume`, `video_source`, `screen_power`, `screen_status`, `screenshot`, `reboot`, `ntp_sync`, `synchronous_playback`, `request_result` +- **Scheduled Control**: `screen_status`, `reboot`, `volume`, `brightness`, `video_source` +- **Solutions**: `emergency_page`, `cancel_emergency`, `common_solution` - **Screens** (VNNOXCare): `list`, `monitor`, `detail` - **Logs**: `control_history` @@ -32,6 +34,28 @@ first_player = players.first statuses = client.players.statuses(player_ids: players.map(&:player_id)) +queue = client.players.config_status( + player_ids: players.map(&:player_id), + notice_url: "https://example.com/status-webhook" +) + +client.control.ntp_sync( + player_ids: players.map(&:player_id), + server: "ntp1.aliyun.com", + enable: true +) + +client.scheduled_control.brightness( + player_ids: players.map(&:player_id), + schedules: { + start_date: Date.today.strftime("%Y-%m-%d"), + end_date: (Date.today + 30).strftime("%Y-%m-%d"), + exec_time: "07:00:00", + type: 0, + value: 55 + } +) + request = client.control.brightness( player_ids: players.map(&:player_id), brightness: 80, @@ -43,6 +67,23 @@ puts result.all_successful? screens = client.screens.list(status: 1) puts screens.first.name + +client.solutions.emergency_page( + player_ids: [first_player.player_id], + attribute: { duration: 20_000, normal_program_status: "PAUSE", spots_type: "IMMEDIATELY" }, + page: { + name: "urgent-alert", + widgets: [ + { + type: "PICTURE", + z_index: 1, + duration: 10_000, + url: "https://example.com/alert.png", + layout: { x: "0%", y: "0%", width: "100%", height: "100%" } + } + ] + } +) ``` ### Development diff --git a/lib/novacloud_client/client.rb b/lib/novacloud_client/client.rb index d6587f8..b233072 100644 --- a/lib/novacloud_client/client.rb +++ b/lib/novacloud_client/client.rb @@ -9,6 +9,8 @@ require_relative "middleware/error_handler" require_relative "resources/players" require_relative "resources/control" +require_relative "resources/scheduled_control" +require_relative "resources/solutions" require_relative "resources/screens" require_relative "resources/logs" @@ -49,6 +51,14 @@ def control @control ||= Resources::Control.new(self) end + def scheduled_control + @scheduled_control ||= Resources::ScheduledControl.new(self) + end + + def solutions + @solutions ||= Resources::Solutions.new(self) + end + def screens @screens ||= Resources::Screens.new(self) end diff --git a/lib/novacloud_client/objects/solutions/publish_result.rb b/lib/novacloud_client/objects/solutions/publish_result.rb new file mode 100644 index 0000000..6dbbe9c --- /dev/null +++ b/lib/novacloud_client/objects/solutions/publish_result.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require_relative "../base" + +module NovacloudClient + module Objects + module Solutions + # Represents the publish result structure returned by solution endpoints. + class PublishResult < Base + attr_reader :successful, :failed + + def success=(value) + @successful = Array(value).compact + end + + def fail=(value) + @failed = Array(value).compact + end + + def all_successful? + failed.empty? + end + + def failed + @failed ||= [] + end + + def successful + @successful ||= [] + end + end + end + end +end diff --git a/lib/novacloud_client/resources/concerns/payload_serializer.rb b/lib/novacloud_client/resources/concerns/payload_serializer.rb new file mode 100644 index 0000000..f1470a3 --- /dev/null +++ b/lib/novacloud_client/resources/concerns/payload_serializer.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module NovacloudClient + module Resources + module Concerns + # Provides camelCase serialization helpers for API payloads. + module PayloadSerializer + private + + def serialize_component(component) + case component + when nil + nil + when Hash + component.each_with_object({}) do |(key, value), result| + result[camelize_key(key)] = serialize_component(value) + end + when Array + component.map { |item| serialize_component(item) } + else + component.respond_to?(:to_h) ? serialize_component(component.to_h) : component + end + end + + def camelize_key(key) + return key unless key.is_a?(String) || key.is_a?(Symbol) + + string = key.to_s + normalized = string + .gsub(/([a-z\d])([A-Z])/, "\\1_\\2") + .tr("-", "_") + .downcase + + parts = normalized.split("_") + camelized = parts.map.with_index do |segment, index| + next segment if index.zero? + + segment.capitalize + end + + camelized.join + end + end + end + end +end diff --git a/lib/novacloud_client/resources/control.rb b/lib/novacloud_client/resources/control.rb index deeb10b..0ad7a18 100644 --- a/lib/novacloud_client/resources/control.rb +++ b/lib/novacloud_client/resources/control.rb @@ -135,6 +135,44 @@ def reboot(player_ids:, notice_url:) ) end + # Configure NTP time synchronization for the selected players. + # + # @param player_ids [Array] NovaCloud player identifiers (max #{MAX_BATCH}) + # @param server [String] NTP server hostname + # @param enable [Boolean] flag indicating whether to enable synchronization + # @return [NovacloudClient::Objects::ControlResult] + def ntp_sync(player_ids:, server:, enable:) + validate_player_ids!(player_ids, max: MAX_BATCH) + validate_presence!(server, "server") + validate_boolean!(enable, "enable") + + payload = { + playerIds: player_ids, + server: server, + enable: !enable.nil? + } + + response = post("/v2/player/real-time-control/ntp", params: payload) + Objects::ControlResult.new(response) + end + + # Toggle synchronous playback mode in real-time. + # + # @param player_ids [Array] NovaCloud player identifiers (max #{MAX_BATCH}) + # @param option [Integer, Symbol, String, TrueClass, FalseClass] desired synchronous state + # @return [NovacloudClient::Objects::ControlResult] + def synchronous_playback(player_ids:, option:) + validate_player_ids!(player_ids, max: MAX_BATCH) + + payload = { + playerIds: player_ids, + option: normalize_sync_option(option) + } + + response = post("/v2/player/real-time-control/simulcast", params: payload) + Objects::ControlResult.new(response) + end + # Fetch the aggregated result of a previously queued control request. # # @param request_id [String] identifier returned by the queueing endpoints @@ -163,6 +201,12 @@ def validate_presence!(value, field) raise ArgumentError, "#{field} is required" if value.to_s.strip.empty? end + def validate_boolean!(value, field) + return if [true, false].include?(value) + + raise ArgumentError, "#{field} must be true or false" + end + def enqueue_control(endpoint:, player_ids:, notice_url:, extra_payload: {}) validate_player_ids!(player_ids, max: MAX_BATCH) validate_notice_url!(notice_url) @@ -192,6 +236,15 @@ def normalize_screen_status(status) raise ArgumentError, "status must be OPEN or CLOSE" end end + + def normalize_sync_option(option) + case option + when true, :on, "on", "ON", 1 then 1 + when false, :off, "off", "OFF", 0 then 0 + else + raise ArgumentError, "option must be one of :on, :off, true, false, or 0/1" + end + end end end end diff --git a/lib/novacloud_client/resources/players.rb b/lib/novacloud_client/resources/players.rb index b2f161b..ebe5470 100644 --- a/lib/novacloud_client/resources/players.rb +++ b/lib/novacloud_client/resources/players.rb @@ -23,6 +23,15 @@ module Resources # ) class Players < Base MAX_BATCH = 100 + CONFIG_STATUS_COMMANDS = %w[ + volumeValue + brightnessValue + videoSourceValue + timeValue + screenPowerStatus + syncPlayStatus + powerStatus + ].freeze # Retrieve players with optional pagination and fuzzy name filtering. # @@ -73,6 +82,22 @@ def running_status(player_ids:, commands:, notice_url:) Objects::QueuedRequest.new(response) end + # Convenience wrapper around `running_status` for configuration polling. + # Uses NovaCloud's recommended command set by default. + def config_status(player_ids:, notice_url:, commands: CONFIG_STATUS_COMMANDS) + running_status(player_ids: player_ids, commands: commands, notice_url: notice_url) + end + + # Delegate to the control resource for NTP time synchronization settings. + def ntp_sync(player_ids:, server:, enable:) + client.control.ntp_sync(player_ids: player_ids, server: server, enable: enable) + end + + # Delegate to the control resource for synchronous playback toggles. + def synchronous_playback(player_ids:, option:) + client.control.synchronous_playback(player_ids: player_ids, option: option) + end + private def build_status_payload(player_ids:, player_sns:) diff --git a/lib/novacloud_client/resources/scheduled_control.rb b/lib/novacloud_client/resources/scheduled_control.rb new file mode 100644 index 0000000..b8da909 --- /dev/null +++ b/lib/novacloud_client/resources/scheduled_control.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require_relative "base" +require_relative "../objects/control_result" +require_relative "../support/key_transform" + +module NovacloudClient + module Resources + # Resource wrapper for scheduled control operations. + class ScheduledControl < Base + MAX_BATCH = 100 + + # Configure scheduled screen status plans for players. + def screen_status(player_ids:, schedules:) + normalized = normalize_schedules(schedules) do |base, original| + wk = fetch_optional(original, :week_days, "week_days", :weekDays, "weekDays") + base[:week_days] = Array(wk || []) + + base[:exec_time] = fetch_required(original, :exec_time, "exec_time", :execTime, "execTime") + + status = fetch_required(original, :status, "status") + base[:status] = normalize_screen_status(status) + base + end + + schedule_request( + endpoint: "/v2/player/scheduled-control/screen-status", + player_ids: player_ids, + schedules: normalized + ) + end + + # Configure scheduled reboots for players. + def reboot(player_ids:, schedules:) + normalized = normalize_schedules(schedules) do |base, original| + base[:exec_time] = fetch_required(original, :exec_time, "exec_time", :execTime, "execTime") + base + end + + schedule_request( + endpoint: "/v2/player/scheduled-control/reboot", + player_ids: player_ids, + schedules: normalized + ) + end + + # Configure scheduled volume adjustments for players. + def volume(player_ids:, schedules:) + normalized = normalize_schedules(schedules) do |base, original| + base[:exec_time] = fetch_required(original, :exec_time, "exec_time", :execTime, "execTime") + + value = fetch_required(original, :value, "value") + validate_percentage!(value, "value") + base[:value] = value.to_i + + wk = fetch_optional(original, :week_days, "week_days", :weekDays, "weekDays") + base[:week_days] = Array(wk || []) + base + end + + schedule_request( + endpoint: "/v2/player/scheduled-control/volume", + player_ids: player_ids, + schedules: normalized + ) + end + + # Configure scheduled brightness adjustments for players. + # @param auto_profile [Hash, nil] optional auto brightness profile + def brightness(player_ids:, schedules:, auto_profile: nil) + normalized = normalize_schedules(schedules) do |base, original| + base[:exec_time] = fetch_required(original, :exec_time, "exec_time", :execTime, "execTime") + + type = fetch_required(original, :type, "type") + base[:type] = type.to_i + + value = fetch_optional(original, :value, "value") + base[:value] = value.to_i if value + base + end + + extra_payload = {} + extra_payload[:auto_profile] = auto_profile if auto_profile + + schedule_request( + endpoint: "/v2/player/scheduled-control/brightness", + player_ids: player_ids, + schedules: normalized, + extra_payload: extra_payload + ) + end + + # Configure scheduled video source switching for players. + def video_source(player_ids:, schedules:) + normalized = normalize_schedules(schedules) do |base, original| + base[:exec_time] = fetch_required(original, :exec_time, "exec_time", :execTime, "execTime") + + source = fetch_required(original, :source, "source") + base[:source] = source + + wk = fetch_optional(original, :week_days, "week_days", :weekDays, "weekDays") + base[:week_days] = Array(wk) unless wk.nil? + + base + end + + schedule_request( + endpoint: "/v2/player/scheduled-control/video-source", + player_ids: player_ids, + schedules: normalized + ) + end + + private + + def normalize_schedules(schedules) + raise ArgumentError, "schedules must be provided" if schedules.nil? + + array = schedules.is_a?(Array) ? schedules : [schedules] + raise ArgumentError, "schedules cannot be empty" if array.empty? + + array.map do |entry| + base, original = build_base_schedule(entry) + block_given? ? yield(base, original) : base + end + end + + def build_base_schedule(entry) + hash = entry.respond_to?(:to_h) ? entry.to_h : entry + raise ArgumentError, "schedule entries must be hash-like" unless hash.is_a?(Hash) + + # Only set date fields; callers will append exec_time, values, and week_days in the exact order needed by each endpoint + base = { + start_date: fetch_required(hash, :start_date, "start_date", :startDate, "startDate"), + end_date: fetch_required(hash, :end_date, "end_date", :endDate, "endDate") + } + + [base, hash] + end + + def schedule_request(endpoint:, player_ids:, schedules:, extra_payload: {}) + validate_player_ids!(player_ids, max: MAX_BATCH) + + payload = { + player_ids: player_ids, + schedules: schedules + } + payload.merge!(extra_payload) if extra_payload && !extra_payload.empty? + cleaned = cleanup_schedule_payload(endpoint, payload) + + response = post(endpoint, params: Support::KeyTransform.camelize_component(cleaned)) + Objects::ControlResult.new(response) + end + + # Remove empty weekDays for endpoints that expect them omitted in tests/docs + def cleanup_schedule_payload(endpoint, payload) + camel_endpoint = endpoint.to_s + omit_empty_week_days = [ + "/v2/player/scheduled-control/brightness", + "/v2/player/scheduled-control/video-source" + ].include?(camel_endpoint) + + return payload unless omit_empty_week_days + + cleaned = Marshal.load(Marshal.dump(payload)) + schedules = cleaned[:schedules] || [] + schedules.each do |sch| + if sch.key?(:week_days) && (sch[:week_days].nil? || (sch[:week_days].respond_to?(:empty?) && sch[:week_days].empty?)) + sch.delete(:week_days) + end + end + + cleaned + end + + def fetch_required(hash, *keys) + value = fetch_optional(hash, *keys) + raise ArgumentError, "#{keys.first} is required" if value.nil? || value.to_s.strip.empty? + + value + end + + def fetch_optional(hash, *keys, default: nil) + keys.each do |key| + string_key = key.to_s + return hash[key] if hash.key?(key) + return hash[string_key] if hash.key?(string_key) + + symbol_key = string_key.to_sym + return hash[symbol_key] if hash.key?(symbol_key) + end + + default + end + + def validate_percentage!(value, field) + int_value = value.to_i + return if int_value.between?(0, 100) + + raise ArgumentError, "#{field} must be between 0 and 100" + end + + def normalize_screen_status(status) + case status + when :open, "open", "OPEN" then "OPEN" + when :close, "close", "CLOSE", :closed then "CLOSE" + else + raise ArgumentError, "status must be OPEN or CLOSE" + end + end + end + end +end diff --git a/lib/novacloud_client/resources/solutions.rb b/lib/novacloud_client/resources/solutions.rb new file mode 100644 index 0000000..79d702a --- /dev/null +++ b/lib/novacloud_client/resources/solutions.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require_relative "base" +require_relative "../objects/solutions/publish_result" +require_relative "../support/key_transform" + +module NovacloudClient + module Resources + # Resource wrapper for solution publishing workflows. + class Solutions < Base + # Publish a single-page emergency insertion program. + # + # @param player_ids [Array] target players + # @param attribute [Hash, #to_h] emergency-specific metadata + # @param page [Hash, #to_h] definition of the single emergency page + # @return [NovacloudClient::Objects::Solutions::PublishResult] + def emergency_page(player_ids:, attribute:, page:) + validate_player_ids!(player_ids) + validate_presence!(attribute, "attribute") + validate_presence!(page, "page") + + payload = Support::KeyTransform.camelize_component( + playerIds: player_ids, + attribute: attribute, + page: page + ) + + response = post("/v2/player/emergency-program/page", params: payload) + build_publish_result(response) + end + + # Cancel any active emergency program for the provided players. + # + # @param player_ids [Array] + # @return [NovacloudClient::Objects::Solutions::PublishResult] + def cancel_emergency(player_ids:) + validate_player_ids!(player_ids) + + payload = Support::KeyTransform.camelize_component(playerIds: player_ids) + response = post("/v2/player/emergency-program/cancel", params: payload) + build_publish_result(response) + end + + # Publish a normal (common) solution schedule to the given players. + # + # @param player_ids [Array] target players + # @param pages [Array] program pages to publish + # @param schedule [Hash, #to_h, nil] optional schedule definition + # @return [NovacloudClient::Objects::Solutions::PublishResult] + def common_solution(player_ids:, pages:, schedule: nil) + validate_player_ids!(player_ids) + validate_presence!(pages, "pages") + + payload = Support::KeyTransform.camelize_component( + playerIds: player_ids, + pages: pages + ) + payload[:schedule] = Support::KeyTransform.camelize_component(schedule) if schedule + + response = post("/v2/player/program/normal", params: payload) + build_publish_result(response) + end + + private + + def build_publish_result(response) + Objects::Solutions::PublishResult.new(response || {}) + end + + def validate_presence!(value, field) + return unless value.nil? || (value.respond_to?(:empty?) && value.empty?) + + raise ArgumentError, "#{field} is required" + end + end + end +end diff --git a/lib/novacloud_client/support/key_transform.rb b/lib/novacloud_client/support/key_transform.rb new file mode 100644 index 0000000..a5ade6a --- /dev/null +++ b/lib/novacloud_client/support/key_transform.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module NovacloudClient + module Support + # Utilities for converting snake_case hashes to camelCase payloads. + module KeyTransform + module_function + + def camelize_component(component) + case component + when Hash + component.each_with_object({}) do |(key, value), result| + result[camelize_key(key)] = camelize_component(value) + end + when Array + component.map { |item| camelize_component(item) } + else + component.respond_to?(:to_h) ? camelize_component(component.to_h) : component + end + end + + def camelize_key(key) + return key unless key.is_a?(String) || key.is_a?(Symbol) + + string = key.to_s + normalized = string.gsub(/([a-z\d])([A-Z])/, "\\1_\\2").gsub("-", "_").downcase + parts = normalized.split("_") + parts.map.with_index { |segment, index| index.zero? ? segment : segment.capitalize }.join + end + end + end +end diff --git a/spec/novacloud_client/resources/control_spec.rb b/spec/novacloud_client/resources/control_spec.rb index 38b40ab..2bddcb7 100644 --- a/spec/novacloud_client/resources/control_spec.rb +++ b/spec/novacloud_client/resources/control_spec.rb @@ -236,4 +236,48 @@ expect { resource.request_result(request_id: " ") }.to raise_error(ArgumentError) end end + + describe "#ntp_sync" do + it "validates enable flag" do + expect do + resource.ntp_sync(player_ids: %w[p1], server: "ntp1.aliyun.com", enable: nil) + end.to raise_error(ArgumentError) + end + + it "publishes ntp sync command" do + stub_request(:post, "https://api.example.com/v2/player/real-time-control/ntp") + .with(body: { + playerIds: %w[p1 p2], + server: "ntp1.aliyun.com", + enable: true + }.to_json) + .to_return(status: 200, body: { "success" => %w[p1], "fail" => %w[p2] }.to_json) + + result = resource.ntp_sync(player_ids: %w[p1 p2], server: "ntp1.aliyun.com", enable: true) + + expect(result).to be_a(NovacloudClient::Objects::ControlResult) + expect(result.partial_success?).to be(true) + end + end + + describe "#synchronous_playback" do + it "normalizes option values" do + stub_request(:post, "https://api.example.com/v2/player/real-time-control/simulcast") + .with(body: { + playerIds: %w[p1], + option: 1 + }.to_json) + .to_return(status: 200, body: { "success" => %w[p1], "fail" => [] }.to_json) + + result = resource.synchronous_playback(player_ids: %w[p1], option: :on) + + expect(result).to be_all_successful + end + + it "raises on invalid option" do + expect do + resource.synchronous_playback(player_ids: %w[p1], option: :invalid) + end.to raise_error(ArgumentError) + end + end end diff --git a/spec/novacloud_client/resources/players_spec.rb b/spec/novacloud_client/resources/players_spec.rb index 517f4ce..bdc3bd8 100644 --- a/spec/novacloud_client/resources/players_spec.rb +++ b/spec/novacloud_client/resources/players_spec.rb @@ -122,4 +122,84 @@ expect(result.request_id).to eq("abc") end end + + describe "#config_status" do + let(:default_commands) do + %w[ + volumeValue + brightnessValue + videoSourceValue + timeValue + screenPowerStatus + syncPlayStatus + powerStatus + ] + end + + it "uses the default configuration command set" do + stub_request(:post, "https://api.example.com/v2/player/current/running-status") + .with(body: { + playerIds: %w[p1], + commands: default_commands, + noticeUrl: "https://callback.example.com" + }.to_json) + .to_return(status: 200, body: { "requestId" => "cfg-1" }.to_json) + + result = resource.config_status( + player_ids: %w[p1], + notice_url: "https://callback.example.com" + ) + + expect(result.request_id).to eq("cfg-1") + end + + it "allows overriding the command list" do + stub_request(:post, "https://api.example.com/v2/player/current/running-status") + .with(body: { + playerIds: %w[p1], + commands: %w[timeValue], + noticeUrl: "https://callback.example.com" + }.to_json) + .to_return(status: 200, body: { "requestId" => "cfg-2" }.to_json) + + result = resource.config_status( + player_ids: %w[p1], + notice_url: "https://callback.example.com", + commands: %w[timeValue] + ) + + expect(result.request_id).to eq("cfg-2") + end + end + + describe "#ntp_sync" do + it "delegates to the control resource" do + stub_request(:post, "https://api.example.com/v2/player/real-time-control/ntp") + .with(body: { + playerIds: %w[p1], + server: "pool.ntp.org", + enable: true + }.to_json) + .to_return(status: 200, body: { "success" => ["p1"], "fail" => [] }.to_json) + + result = resource.ntp_sync(player_ids: %w[p1], server: "pool.ntp.org", enable: true) + + expect(result).to be_all_successful + end + end + + describe "#synchronous_playback" do + it "delegates to the control resource" do + stub_request(:post, "https://api.example.com/v2/player/real-time-control/simulcast") + .with(body: { + playerIds: %w[p1], + option: 1 + }.to_json) + .to_return(status: 200, body: { "success" => ["p1"], "fail" => [] }.to_json) + + result = resource.synchronous_playback(player_ids: %w[p1], option: :on) + + expect(result).to be_all_successful + end + end end diff --git a/spec/novacloud_client/resources/scheduled_control_spec.rb b/spec/novacloud_client/resources/scheduled_control_spec.rb new file mode 100644 index 0000000..6661e9f --- /dev/null +++ b/spec/novacloud_client/resources/scheduled_control_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +RSpec.describe NovacloudClient::Resources::ScheduledControl do + subject(:resource) { described_class.new(client) } + + let(:client) do + NovacloudClient::Client.new( + app_key: "key", + app_secret: "secret", + service_domain: "api.example.com" + ) + end + + describe "#screen_status" do + it "normalizes status values and camelizes schedule keys" do + stub_request(:post, "https://api.example.com/v2/player/scheduled-control/screen-status") + .with(body: { + playerIds: ["p1"], + schedules: [ + { + startDate: "2025-01-01", + endDate: "2025-12-31", + weekDays: [1, 2], + execTime: "06:30:00", + status: "OPEN" + } + ] + }.to_json) + .to_return(status: 200, body: { "success" => ["p1"], "fail" => [] }.to_json) + + result = resource.screen_status( + player_ids: ["p1"], + schedules: { + start_date: "2025-01-01", + end_date: "2025-12-31", + week_days: [1, 2], + exec_time: "06:30:00", + status: :open + } + ) + + expect(result).to be_a(NovacloudClient::Objects::ControlResult) + expect(result).to be_all_successful + end + + it "requires schedules" do + expect do + resource.screen_status(player_ids: ["p1"], schedules: []) + end.to raise_error(ArgumentError) + end + end + + describe "#brightness" do + it "supports optional auto profile" do + stub_request(:post, "https://api.example.com/v2/player/scheduled-control/brightness") + .with(body: { + playerIds: ["p1"], + schedules: [ + { + startDate: "2025-01-01", + endDate: "2025-12-31", + execTime: "07:00:00", + type: 1, + value: 40 + } + ], + autoProfile: { + failValue: 50, + segments: [ + { + id: 0, + environmentBrightness: 500, + screenBrightness: 20 + } + ] + } + }.to_json) + .to_return(status: 200, body: { "success" => ["p1"], "fail" => [] }.to_json) + + result = resource.brightness( + player_ids: ["p1"], + schedules: [ + { + start_date: "2025-01-01", + end_date: "2025-12-31", + exec_time: "07:00:00", + type: 1, + value: 40 + } + ], + auto_profile: { + fail_value: 50, + segments: [ + { + id: 0, + environment_brightness: 500, + screen_brightness: 20 + } + ] + } + ) + + expect(result).to be_all_successful + end + end + + describe "#volume" do + it "schedules volume changes" do + stub_request(:post, "https://api.example.com/v2/player/scheduled-control/volume") + .with(body: { + playerIds: ["p1"], + schedules: [ + { + startDate: "2025-01-01", + endDate: "2025-12-31", + execTime: "08:00:00", + value: 55, + weekDays: [] + } + ] + }.to_json) + .to_return(status: 200, body: { "success" => ["p1"], "fail" => [] }.to_json) + + result = resource.volume( + player_ids: ["p1"], + schedules: { + start_date: "2025-01-01", + end_date: "2025-12-31", + exec_time: "08:00:00", + value: 55 + } + ) + + expect(result).to be_all_successful + end + + it "raises when schedules are nil" do + expect do + resource.volume(player_ids: ["p1"], schedules: nil) + end.to raise_error(ArgumentError) + end + + it "validates volume limits" do + expect do + resource.volume(player_ids: ["p1"], schedules: { + start_date: "2025-01-01", + end_date: "2025-12-31", + exec_time: "08:00:00", + value: 120 + }) + end.to raise_error(ArgumentError) + end + end + + describe "#video_source" do + it "passes the video source option" do + stub_request(:post, "https://api.example.com/v2/player/scheduled-control/video-source") + .with(body: { + "playerIds" => ["p1"], + "schedules" => [ + { + "startDate" => "2025-01-01", + "endDate" => "2025-12-31", + "execTime" => "09:00:00", + "source" => 1 + } + ] + }.to_json) + .to_return(status: 200, body: { "success" => ["p1"], "fail" => [] }.to_json) + + resource.video_source( + player_ids: ["p1"], + schedules: { + start_date: "2025-01-01", + end_date: "2025-12-31", + exec_time: "09:00:00", + source: 1 + } + ) + end + end +end diff --git a/spec/novacloud_client/resources/solutions_spec.rb b/spec/novacloud_client/resources/solutions_spec.rb new file mode 100644 index 0000000..283978b --- /dev/null +++ b/spec/novacloud_client/resources/solutions_spec.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +RSpec.describe NovacloudClient::Resources::Solutions do + subject(:resource) { described_class.new(client) } + + let(:client) do + NovacloudClient::Client.new( + app_key: "key", + app_secret: "secret", + service_domain: "api.example.com" + ) + end + + describe "#emergency_page" do + it "serializes snake_case keys to the API payload" do + stub_request(:post, "https://api.example.com/v2/player/emergency-program/page") + .with(body: { + playerIds: ["p1"], + attribute: { + duration: 20_000, + normalProgramStatus: "PAUSE", + spotsType: "IMMEDIATELY" + }, + page: { + name: "urgent", + widgets: [ + { + type: "PICTURE", + zIndex: 1, + duration: 10_000, + layout: { x: "0%", y: "0%", width: "100%", height: "100%" }, + inAnimation: { type: "NONE", duration: 1_000 } + } + ] + } + }.to_json) + .to_return( + status: 200, + body: { "success" => ["p1"], "fail" => [] }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + result = resource.emergency_page( + player_ids: ["p1"], + attribute: { duration: 20_000, normal_program_status: "PAUSE", spots_type: "IMMEDIATELY" }, + page: { + name: "urgent", + widgets: [ + { + type: "PICTURE", + z_index: 1, + duration: 10_000, + layout: { x: "0%", y: "0%", width: "100%", height: "100%" }, + in_animation: { type: "NONE", duration: 1_000 } + } + ] + } + ) + + expect(result).to be_a(NovacloudClient::Objects::Solutions::PublishResult) + expect(result).to be_all_successful + expect(result.successful).to eq(["p1"]) + end + + it "requires an attribute payload" do + expect do + resource.emergency_page(player_ids: ["p1"], attribute: nil, page: { name: "test" }) + end.to raise_error(ArgumentError) + end + end + + describe "#cancel_emergency" do + it "publishes a cancel command" do + stub_request(:post, "https://api.example.com/v2/player/emergency-program/cancel") + .with(body: { playerIds: %w[p1 p2] }.to_json) + .to_return(status: 200, body: { "success" => ["p1"], "fail" => ["p2"] }.to_json) + + result = resource.cancel_emergency(player_ids: %w[p1 p2]) + + expect(result.failed).to eq(["p2"]) + expect(result.successful).to eq(["p1"]) + end + end + + describe "#common_solution" do + it "camelizes nested schedule and widget keys" do + stub_request(:post, "https://api.example.com/v2/player/program/normal") + .with(body: { + playerIds: ["p1"], + pages: [ + { + name: "main", + widgets: [ + { + type: "VIDEO", + zIndex: 2, + duration: 0, + url: "https://cdn.example.com/video.mp4", + layout: { x: "0%", y: "0%", width: "100%", height: "100%" } + } + ] + } + ], + schedule: { + startDate: "2024-01-01", + endDate: "2024-12-31", + plans: [ + { + weekDays: [1, 2, 3, 4, 5], + startTime: "08:00:00", + endTime: "18:00:00" + } + ] + } + }.to_json) + .to_return(status: 200, body: { "success" => ["p1"], "fail" => [] }.to_json) + + result = resource.common_solution( + player_ids: ["p1"], + pages: [ + { + name: "main", + widgets: [ + { + type: "VIDEO", + z_index: 2, + duration: 0, + url: "https://cdn.example.com/video.mp4", + layout: { x: "0%", y: "0%", width: "100%", height: "100%" } + } + ] + } + ], + schedule: { + start_date: "2024-01-01", + end_date: "2024-12-31", + plans: [ + { + week_days: [1, 2, 3, 4, 5], + start_time: "08:00:00", + end_time: "18:00:00" + } + ] + } + ) + + expect(result).to be_all_successful + end + + it "requires pages" do + expect do + resource.common_solution(player_ids: ["p1"], pages: []) + end.to raise_error(ArgumentError) + end + end +end From da796e47dbc67ff99f36f93477911e3ae19f8226 Mon Sep 17 00:00:00 2001 From: Chayut Orapinpatipat Date: Mon, 27 Oct 2025 14:26:51 +1100 Subject: [PATCH 2/2] pass 2 --- .todo/scheduled_control_fix.json | 14 - README.md | 39 +- .../solutions/offline_export_result.rb | 80 ++++ .../solutions/over_spec_detection_result.rb | 80 ++++ .../objects/solutions/publish_result.rb | 2 - .../resources/scheduled_control.rb | 34 +- lib/novacloud_client/resources/solutions.rb | 77 ++++ novacloud_client.gemspec | 8 +- .../resources/solutions_spec.rb | 343 ++++++++++++++++++ 9 files changed, 643 insertions(+), 34 deletions(-) delete mode 100644 .todo/scheduled_control_fix.json create mode 100644 lib/novacloud_client/objects/solutions/offline_export_result.rb create mode 100644 lib/novacloud_client/objects/solutions/over_spec_detection_result.rb diff --git a/.todo/scheduled_control_fix.json b/.todo/scheduled_control_fix.json deleted file mode 100644 index 9e2a355..0000000 --- a/.todo/scheduled_control_fix.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "tasks": [ - { - "id": 1, - "title": "Ensure week_days omitted when empty for scheduled control payloads", - "status": "in_progress" - }, - { - "id": 2, - "title": "Align video_source schedule payload validation with spec expectations", - "status": "in_progress" - } - ] -} \ No newline at end of file diff --git a/README.md b/README.md index ee50063..84655ed 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,16 @@ - **Players**: `list`, `statuses`, `running_status` - **Control**: `brightness`, `volume`, `video_source`, `screen_power`, `screen_status`, `screenshot`, `reboot`, `ntp_sync`, `synchronous_playback`, `request_result` - **Scheduled Control**: `screen_status`, `reboot`, `volume`, `brightness`, `video_source` -- **Solutions**: `emergency_page`, `cancel_emergency`, `common_solution` +- **Solutions**: `emergency_page`, `cancel_emergency`, `common_solution`, `offline_export`, `set_over_spec_detection`, `program_over_spec_detection` - **Screens** (VNNOXCare): `list`, `monitor`, `detail` - **Logs**: `control_history` +> **Heads-up:** NovaCloud's public API docs (as of October 2025) do not expose +> "material" endpoints for uploading, listing, or deleting media assets. This +> client therefore expects assets to be hosted already (either uploaded via the +> VNNOX UI or served from your own CDN) and referenced by URL in solution +> payloads. + ## Quick Start ```ruby @@ -84,6 +90,37 @@ client.solutions.emergency_page( ] } ) + +offline_bundle = client.solutions.offline_export( + program_type: 1, + plan_version: "V2", + pages: [ + { + name: "main", + widgets: [ + { type: "PICTURE", md5: "abc", url: "https://cdn.example.com/img.jpg" } + ] + } + ] +) + +puts offline_bundle.plan_json.url + +over_spec_result = client.solutions.program_over_spec_detection( + player_ids: [first_player.player_id], + pages: [ + { + page_id: 1, + widgets: [ + { widget_id: 1, type: "VIDEO", url: "https://cdn.example.com/video.mp4", width: "3840", height: "2160" } + ] + } + ] +) + +if over_spec_result.items.any?(&:over_spec?) + warn "Program exceeds specifications" +end ``` ### Development diff --git a/lib/novacloud_client/objects/solutions/offline_export_result.rb b/lib/novacloud_client/objects/solutions/offline_export_result.rb new file mode 100644 index 0000000..ae6b62f --- /dev/null +++ b/lib/novacloud_client/objects/solutions/offline_export_result.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require_relative "../base" + +module NovacloudClient + module Objects + module Solutions + # Represents the payload returned from the offline export endpoint. + class OfflineExportResult < Base + attr_reader :display_solutions, :play_relations, :play_solutions, + :playlists, :schedule_constraints, :plan_json + + def display_solutions=(value) + @display_solutions = build_artifact(value) + end + + def play_relations=(value) + @play_relations = build_artifact(value) + end + + def play_solutions=(value) + @play_solutions = build_artifact(value) + end + + def playlists=(value) + @playlists = build_artifact(value) + end + + def schedule_constraints=(value) + @schedule_constraints = build_artifact(value) + end + + def plan_json=(value) + @plan_json = build_artifact(value) + end + + private + + def build_artifact(value) + return nil if value.nil? + + if value.is_a?(Array) + value.map { |artifact| Artifact.new(artifact) } + else + Artifact.new(value) + end + end + + # Represents a downloadable artifact (JSON, playlist, etc.) returned by the export. + class Artifact < Base + attr_accessor :md5, :file_name, :url, :program_name + + attr_writer :support_md5_checkout + + def support_md5_checkout + return nil if @support_md5_checkout.nil? + + !!@support_md5_checkout + end + + def support_md5_checkout? + support_md5_checkout + end + + # Legacy camelCase accessors for backward compatibility with existing clients. + # Legacy camelCase accessors for backward compatibility with existing clients. + # rubocop:disable Naming/PredicatePrefix + def is_support_md5_checkout? + support_md5_checkout? + end + + def is_support_md5_checkout=(value) + self.support_md5_checkout = value + end + # rubocop:enable Naming/PredicatePrefix + end + end + end + end +end diff --git a/lib/novacloud_client/objects/solutions/over_spec_detection_result.rb b/lib/novacloud_client/objects/solutions/over_spec_detection_result.rb new file mode 100644 index 0000000..7d93535 --- /dev/null +++ b/lib/novacloud_client/objects/solutions/over_spec_detection_result.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require_relative "../base" + +module NovacloudClient + module Objects + module Solutions + # Represents the payload returned when checking program over-specification. + class OverSpecDetectionResult < Base + attr_accessor :logid, :status + attr_reader :items + + def initialize(attributes = {}) + @items = [] + super + end + + def data=(value) + @items = Array(value).map { |entry| Item.new(entry) } + end + + alias data items + + # Entry describing over-specification findings for a specific set of players. + class Item < Base + attr_accessor :over_spec_type + attr_reader :over_spec, :player_ids + + def initialize(attributes = {}) + @details = [] + super + end + + def over_spec=(value) + @over_spec = !!value + end + + def over_spec? + over_spec + end + + def player_ids=(value) + @player_ids = Array(value) + end + + def over_spec_detail=(value) + @details = Array(value).map { |detail| Detail.new(detail) } + end + + def details + @details ||= [] + end + + # Detail record for a widget that exceeds specifications. + class Detail < Base + attr_accessor :page_id, :widget_id + attr_reader :over_spec_error_code, :recommendation + + def over_spec_error_code=(value) + @over_spec_error_code = Array(value) + end + + def over_spec_error_codes + @over_spec_error_code + end + + def recommend=(value) + @recommendation = value ? Recommendation.new(value) : nil + end + + # Suggested adjustments for bringing a widget within spec. + class Recommendation < Base + attr_accessor :width, :height, :postfix, :fps, :byte_rate, :codec + end + end + end + end + end + end +end diff --git a/lib/novacloud_client/objects/solutions/publish_result.rb b/lib/novacloud_client/objects/solutions/publish_result.rb index 6dbbe9c..c96f814 100644 --- a/lib/novacloud_client/objects/solutions/publish_result.rb +++ b/lib/novacloud_client/objects/solutions/publish_result.rb @@ -7,8 +7,6 @@ module Objects module Solutions # Represents the publish result structure returned by solution endpoints. class PublishResult < Base - attr_reader :successful, :failed - def success=(value) @successful = Array(value).compact end diff --git a/lib/novacloud_client/resources/scheduled_control.rb b/lib/novacloud_client/resources/scheduled_control.rb index b8da909..8399483 100644 --- a/lib/novacloud_client/resources/scheduled_control.rb +++ b/lib/novacloud_client/resources/scheduled_control.rb @@ -129,7 +129,8 @@ def build_base_schedule(entry) hash = entry.respond_to?(:to_h) ? entry.to_h : entry raise ArgumentError, "schedule entries must be hash-like" unless hash.is_a?(Hash) - # Only set date fields; callers will append exec_time, values, and week_days in the exact order needed by each endpoint + # Only set date fields; callers append exec_time, values, and week_days + # for each endpoint-specific payload requirement. base = { start_date: fetch_required(hash, :start_date, "start_date", :startDate, "startDate"), end_date: fetch_required(hash, :end_date, "end_date", :endDate, "endDate") @@ -154,25 +155,32 @@ def schedule_request(endpoint:, player_ids:, schedules:, extra_payload: {}) # Remove empty weekDays for endpoints that expect them omitted in tests/docs def cleanup_schedule_payload(endpoint, payload) - camel_endpoint = endpoint.to_s - omit_empty_week_days = [ - "/v2/player/scheduled-control/brightness", - "/v2/player/scheduled-control/video-source" - ].include?(camel_endpoint) - - return payload unless omit_empty_week_days + return payload unless omit_empty_week_days?(endpoint) cleaned = Marshal.load(Marshal.dump(payload)) - schedules = cleaned[:schedules] || [] - schedules.each do |sch| - if sch.key?(:week_days) && (sch[:week_days].nil? || (sch[:week_days].respond_to?(:empty?) && sch[:week_days].empty?)) - sch.delete(:week_days) - end + Array(cleaned[:schedules]).each do |schedule| + next unless schedule.key?(:week_days) + + schedule.delete(:week_days) if blank_week_days?(schedule[:week_days]) end cleaned end + def omit_empty_week_days?(endpoint) + [ + "/v2/player/scheduled-control/brightness", + "/v2/player/scheduled-control/video-source" + ].include?(endpoint.to_s) + end + + def blank_week_days?(values) + return true if values.nil? + return values.empty? if values.respond_to?(:empty?) + + false + end + def fetch_required(hash, *keys) value = fetch_optional(hash, *keys) raise ArgumentError, "#{keys.first} is required" if value.nil? || value.to_s.strip.empty? diff --git a/lib/novacloud_client/resources/solutions.rb b/lib/novacloud_client/resources/solutions.rb index 79d702a..d348906 100644 --- a/lib/novacloud_client/resources/solutions.rb +++ b/lib/novacloud_client/resources/solutions.rb @@ -2,6 +2,8 @@ require_relative "base" require_relative "../objects/solutions/publish_result" +require_relative "../objects/solutions/offline_export_result" +require_relative "../objects/solutions/over_spec_detection_result" require_relative "../support/key_transform" module NovacloudClient @@ -61,8 +63,77 @@ def common_solution(player_ids:, pages:, schedule: nil) build_publish_result(response) end + # Export an offline program bundle without publishing it to devices. + # + # @param program_type [Integer] NovaCloud program type identifier + # @param pages [Array] program pages to include + # @param plan_version [String, nil] optional plan version (defaults to V2 when provided) + # @param schedule [Hash, #to_h, nil] optional playback schedule definition + # @param options [Hash] additional top-level attributes supported by the API + # @return [NovacloudClient::Objects::Solutions::OfflineExportResult] + def offline_export(program_type:, pages:, plan_version: nil, schedule: nil, **options) + validate_presence!(pages, "pages") + raise ArgumentError, "program_type is required" if program_type.nil? + + payload = { + program_type: program_type, + pages: pages, + plan_version: plan_version, + schedule: schedule + }.merge(options) + + camelized = Support::KeyTransform.camelize_component(compact_hash(payload)) + + response = post("/v2/player/program/offline-export", params: camelized) + Objects::Solutions::OfflineExportResult.new(response || {}) + end + + # Enable or disable over-specification detection on the selected players. + # + # @param player_ids [Array] target players + # @param enable [Boolean] whether detection should be enabled + # @return [NovacloudClient::Objects::Solutions::PublishResult] + def set_over_spec_detection(player_ids:, enable:) + validate_player_ids!(player_ids) + validate_boolean!(enable, "enable") + + payload = { + playerIds: player_ids, + enable: enable + } + + response = post("/v2/player/immediateControl/over-specification-options", params: payload) + build_publish_result(response) + end + + # Validate whether a program will exceed the hardware specifications. + # + # @param player_ids [Array] players against which to test + # @param pages [Array] the program definition to analyse + # @return [NovacloudClient::Objects::Solutions::OverSpecDetectionResult] + def program_over_spec_detection(player_ids:, pages:) + validate_player_ids!(player_ids) + validate_presence!(pages, "pages") + + payload = Support::KeyTransform.camelize_component( + playerIds: player_ids, + pages: pages + ) + + response = post("/v2/player/program/over-specification-check", params: payload) + Objects::Solutions::OverSpecDetectionResult.new(response || {}) + end + private + def compact_hash(hash) + hash.each_with_object({}) do |(key, value), result| + next if value.nil? + + result[key] = value + end + end + def build_publish_result(response) Objects::Solutions::PublishResult.new(response || {}) end @@ -72,6 +143,12 @@ def validate_presence!(value, field) raise ArgumentError, "#{field} is required" end + + def validate_boolean!(value, field) + return if [true, false].include?(value) + + raise ArgumentError, "#{field} must be true or false" + end end end end diff --git a/novacloud_client.gemspec b/novacloud_client.gemspec index f6a7282..dc0fee9 100644 --- a/novacloud_client.gemspec +++ b/novacloud_client.gemspec @@ -6,19 +6,19 @@ Gem::Specification.new do |spec| spec.name = "novacloud_client" spec.version = NovacloudClient::VERSION spec.authors = ["Chayut Orapinpatipat"] - spec.email = ["chayut@canopusnet.com"] + spec.email = ["dev@sentia.com.au"] spec.summary = "Ruby client for the NovaCloud API." spec.description = <<~DESC.strip Faraday-based Ruby client for the NovaCloud Open Platform. Handles authentication, error mapping, and resource abstractions. DESC - spec.homepage = "https://github.com/canopusnet/novacloud_client" + spec.homepage = "https://github.com/Sentia/novacloud_client" spec.license = "MIT" spec.required_ruby_version = ">= 2.7.0" spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = spec.homepage - spec.metadata["changelog_uri"] = "https://github.com/canopusnet/novacloud_client/blob/main/CHANGELOG.md" + spec.metadata["changelog_uri"] = "https://github.com/Sentia/novacloud_client/blob/main/CHANGELOG.md" spec.metadata["rubygems_mfa_required"] = "true" # Specify which files should be added to the gem when it is released. @@ -27,7 +27,7 @@ Gem::Specification.new do |spec| spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| ls.readlines("\x0", chomp: true).reject do |f| (f == gemspec) || - f.start_with?(*%w[bin/ Gemfile .gitignore .rspec spec/ .github/ .rubocop.yml]) + f.start_with?(*%w[bin/ Gemfile .gitignore .rspec spec/ .github/ .rubocop.yml .todo/]) end end spec.bindir = "bin" diff --git a/spec/novacloud_client/resources/solutions_spec.rb b/spec/novacloud_client/resources/solutions_spec.rb index 283978b..e3cc788 100644 --- a/spec/novacloud_client/resources/solutions_spec.rb +++ b/spec/novacloud_client/resources/solutions_spec.rb @@ -67,6 +67,18 @@ resource.emergency_page(player_ids: ["p1"], attribute: nil, page: { name: "test" }) end.to raise_error(ArgumentError) end + + it "requires a page payload" do + expect do + resource.emergency_page(player_ids: ["p1"], attribute: { duration: 1000 }, page: nil) + end.to raise_error(ArgumentError) + end + + it "requires player_ids" do + expect do + resource.emergency_page(player_ids: [], attribute: {}, page: {}) + end.to raise_error(ArgumentError) + end end describe "#cancel_emergency" do @@ -79,6 +91,13 @@ expect(result.failed).to eq(["p2"]) expect(result.successful).to eq(["p1"]) + expect(result).not_to be_all_successful + end + + it "requires player_ids" do + expect do + resource.cancel_emergency(player_ids: []) + end.to raise_error(ArgumentError) end end @@ -147,10 +166,334 @@ expect(result).to be_all_successful end + it "works without a schedule" do + stub_request(:post, "https://api.example.com/v2/player/program/normal") + .with(body: { + playerIds: ["p1"], + pages: [{ name: "page1", widgets: [] }] + }.to_json) + .to_return(status: 200, body: { "success" => ["p1"], "fail" => [] }.to_json) + + result = resource.common_solution( + player_ids: ["p1"], + pages: [{ name: "page1", widgets: [] }] + ) + + expect(result).to be_all_successful + end + it "requires pages" do expect do resource.common_solution(player_ids: ["p1"], pages: []) end.to raise_error(ArgumentError) end + + it "requires player_ids" do + expect do + resource.common_solution(player_ids: [], pages: [{}]) + end.to raise_error(ArgumentError) + end + end + + describe "#offline_export" do + it "camelizes payload and wraps the response" do + stub_request(:post, "https://api.example.com/v2/player/program/offline-export") + .with(body: { + programType: 1, + pages: [ + { + name: "page-1", + widgets: [ + { type: "PICTURE", md5: "abc", url: "http://example.com/image.jpg" } + ] + } + ], + planVersion: "V2", + schedule: { + startDate: "2024-01-01", + endDate: "2024-12-31" + } + }.to_json) + .to_return( + status: 200, + body: { + "displaySolutions" => { "md5" => "123", "fileName" => "display.json", "url" => "https://cdn/display.json" }, + "planJson" => { + "md5" => "456", + "fileName" => "plan.json", + "url" => "https://cdn/plan.json", + "isSupportMd5Checkout" => true, + "programName" => "API-202409241354552904-Program" + } + }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + result = resource.offline_export( + program_type: 1, + plan_version: "V2", + pages: [ + { + name: "page-1", + widgets: [ + { type: "PICTURE", md5: "abc", url: "http://example.com/image.jpg" } + ] + } + ], + schedule: { + start_date: "2024-01-01", + end_date: "2024-12-31" + } + ) + + expect(result).to be_a(NovacloudClient::Objects::Solutions::OfflineExportResult) + expect(result.display_solutions.file_name).to eq("display.json") + expect(result.display_solutions.md5).to eq("123") + expect(result.plan_json.is_support_md5_checkout?).to be(true) + expect(result.plan_json.program_name).to eq("API-202409241354552904-Program") + end + + it "handles array artifacts" do + stub_request(:post, "https://api.example.com/v2/player/program/offline-export") + .with(body: hash_including("programType" => 1)) + .to_return( + status: 200, + body: { + "playlists" => [ + { "md5" => "abc1", "fileName" => "playlist1.json", "url" => "https://cdn/p1.json" }, + { "md5" => "abc2", "fileName" => "playlist2.json", "url" => "https://cdn/p2.json" } + ] + }.to_json + ) + + result = resource.offline_export(program_type: 1, pages: [{ name: "test" }]) + + expect(result.playlists).to be_an(Array) + expect(result.playlists.size).to eq(2) + expect(result.playlists.first.file_name).to eq("playlist1.json") + expect(result.playlists.last.md5).to eq("abc2") + end + + it "omits nil values from payload" do + stub_request(:post, "https://api.example.com/v2/player/program/offline-export") + .with(body: { + programType: 1, + pages: [{ name: "test" }] + }.to_json) + .to_return(status: 200, body: {}.to_json) + + resource.offline_export( + program_type: 1, + pages: [{ name: "test" }], + plan_version: nil, + schedule: nil + ) + end + + it "requires a program_type" do + expect do + resource.offline_export(program_type: nil, pages: [{}]) + end.to raise_error(ArgumentError) + end + + it "requires pages" do + expect do + resource.offline_export(program_type: 1, pages: []) + end.to raise_error(ArgumentError) + end + + it "accepts additional options" do + stub_request(:post, "https://api.example.com/v2/player/program/offline-export") + .with(body: hash_including("programType" => 1, "customField" => "value")) + .to_return(status: 200, body: {}.to_json) + + result = resource.offline_export( + program_type: 1, + pages: [{ name: "test" }], + custom_field: "value" + ) + + expect(result).to be_a(NovacloudClient::Objects::Solutions::OfflineExportResult) + end + end + + describe "#set_over_spec_detection" do + it "validates boolean flag and player ids" do + stub_request(:post, "https://api.example.com/v2/player/immediateControl/over-specification-options") + .with(body: { playerIds: ["p1"], enable: false }.to_json) + .to_return(status: 200, body: { "success" => ["p1"], "fail" => [] }.to_json) + + result = resource.set_over_spec_detection(player_ids: ["p1"], enable: false) + + expect(result).to be_a(NovacloudClient::Objects::Solutions::PublishResult) + expect(result.successful).to eq(["p1"]) + end + + it "accepts true flag" do + stub_request(:post, "https://api.example.com/v2/player/immediateControl/over-specification-options") + .with(body: { playerIds: ["p1"], enable: true }.to_json) + .to_return(status: 200, body: { "success" => ["p1"], "fail" => [] }.to_json) + + result = resource.set_over_spec_detection(player_ids: ["p1"], enable: true) + + expect(result).to be_all_successful + end + + it "rejects non-boolean enable values" do + expect do + resource.set_over_spec_detection(player_ids: ["p1"], enable: "nope") + end.to raise_error(ArgumentError, /must be true or false/) + end + + it "rejects nil enable" do + expect do + resource.set_over_spec_detection(player_ids: ["p1"], enable: nil) + end.to raise_error(ArgumentError) + end + + it "requires player_ids" do + expect do + resource.set_over_spec_detection(player_ids: [], enable: true) + end.to raise_error(ArgumentError) + end + end + + describe "#program_over_spec_detection" do + it "camelizes payload and builds result objects" do + stub_request(:post, "https://api.example.com/v2/player/program/over-specification-check") + .with(body: { + playerIds: ["p1"], + pages: [ + { + pageId: 1, + widgets: [ + { widgetId: 1, type: "PICTURE", md5: "abc", url: "http://example.com/img.jpg" } + ] + } + ] + }.to_json) + .to_return( + status: 200, + body: { + "logid" => 111, + "status" => 0, + "data" => [ + { + "overSpec" => true, + "overSpecType" => 1, + "playerIds" => ["p1"], + "overSpecDetail" => [ + { + "pageId" => 1, + "widgetId" => 1, + "overSpecErrorCode" => [-20, -21], + "recommend" => { + "width" => "1920", + "height" => "1080", + "fps" => "30", + "byteRate" => "78.000000", + "codec" => "h264" + } + } + ] + } + ] + }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + result = resource.program_over_spec_detection( + player_ids: ["p1"], + pages: [ + { + page_id: 1, + widgets: [ + { widget_id: 1, type: "PICTURE", md5: "abc", url: "http://example.com/img.jpg" } + ] + } + ] + ) + + expect(result).to be_a(NovacloudClient::Objects::Solutions::OverSpecDetectionResult) + expect(result.logid).to eq(111) + expect(result.status).to eq(0) + expect(result.items).to be_an(Array) + expect(result.items.size).to eq(1) + + item = result.items.first + expect(item).to be_over_spec + expect(item.over_spec_type).to eq(1) + expect(item.player_ids).to eq(["p1"]) + expect(item.details).to be_an(Array) + + detail = item.details.first + expect(detail.page_id).to eq(1) + expect(detail.widget_id).to eq(1) + expect(detail.over_spec_error_codes).to eq([-20, -21]) + + recommendation = detail.recommendation + expect(recommendation).not_to be_nil + expect(recommendation.fps).to eq("30") + expect(recommendation.width).to eq("1920") + expect(recommendation.codec).to eq("h264") + end + + it "handles players without over-spec issues" do + stub_request(:post, "https://api.example.com/v2/player/program/over-specification-check") + .with(body: hash_including("playerIds" => ["p2"])) + .to_return( + status: 200, + body: { + "logid" => 222, + "status" => 0, + "data" => [ + { + "overSpec" => false, + "playerIds" => ["p2"] + } + ] + }.to_json + ) + + result = resource.program_over_spec_detection( + player_ids: ["p2"], + pages: [{ page_id: 1, widgets: [] }] + ) + + item = result.items.first + expect(item.over_spec?).to be(false) + expect(item.details).to be_empty + end + + it "handles multiple items in response" do + stub_request(:post, "https://api.example.com/v2/player/program/over-specification-check") + .to_return( + status: 200, + body: { + "data" => [ + { "overSpec" => false, "playerIds" => ["p1"] }, + { "overSpec" => true, "overSpecType" => 1, "playerIds" => ["p2"], "overSpecDetail" => [] } + ] + }.to_json + ) + + result = resource.program_over_spec_detection(player_ids: %w[p1 p2], pages: [{}]) + + expect(result.items.size).to eq(2) + expect(result.items.first.over_spec?).to be(false) + expect(result.items.last.over_spec?).to be(true) + end + + it "requires player_ids" do + expect do + resource.program_over_spec_detection(player_ids: [], pages: [{}]) + end.to raise_error(ArgumentError) + end + + it "requires pages" do + expect do + resource.program_over_spec_detection(player_ids: ["p1"], pages: []) + end.to raise_error(ArgumentError) + end end end