diff --git a/README.md b/README.md index c7759ef..8926ff1 100644 --- a/README.md +++ b/README.md @@ -2,42 +2,27 @@ A Ruby gem for interacting with the NovaCloud Open Platform API. This client provides: -- Simple configuration with `app_key`, `app_secret`, and `service_domain` -- Automatic authentication headers via Faraday middleware -- Typed exception hierarchy for HTTP errors -- JSON request/response handling -- Resource-based API wrappers with typed response objects +- Manages configuration once (`app_key`, `app_secret`, `service_domain`). +- Handles authentication headers automatically via Faraday middleware. +- Maps HTTP errors to a typed exception hierarchy. +- Normalizes GET/POST payloads and parses JSON responses. -## Resource Overview - -The gem currently implements the following NovaCloud API resources: - -- **Players**: `list`, `statuses`, `running_status` - Player management and status queries -- **Control**: `brightness`, `volume`, `video_source`, `screen_power`, `screen_status`, `screenshot`, `reboot`, `request_result` - Real-time player control commands -- **Screens** (VNNOXCare): `list`, `monitor`, `detail` - Screen device status monitoring -- **Logs**: `control_history` - Control command execution history - -**Note**: The gem focuses on the most commonly used endpoints. Additional endpoints like Solutions (emergency insertion, offline export), Scheduled Control, and Play Logs are not yet implemented but can be added based on demand. + 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`). -## Installation - -Add this line to your application's Gemfile: - -```ruby -gem 'novacloud_client' -``` - -And then execute: - -```bash -bundle install -``` +## Resource Overview -Or install it yourself as: +- **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`, `offline_export`, `set_over_spec_detection`, `program_over_spec_detection` +- **Screens** (VNNOXCare): `list`, `monitor`, `detail` +- **Logs**: `control_history` -```bash -gem install novacloud_client -``` +> **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 @@ -61,7 +46,28 @@ statuses.each do |status| puts "Player #{status.player_id}: online=#{status.online?}" end -# Control player brightness +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, @@ -74,12 +80,54 @@ puts "All successful? #{result.all_successful?}" # List screens (VNNOXCare) screens = client.screens.list(status: 1) -puts "Screen: #{screens.first.name}" if screens.any? +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%" } + } + ] + } +) + +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" } + ] + } + ] +) -# View control command history -logs = client.logs.control_history(player_id: players.first.player_id) -logs.each do |log| - puts "#{log.time}: #{log.task_name} - #{log.status}" +if over_spec_result.items.any?(&:over_spec?) + warn "Program exceeds specifications" end ``` 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/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 new file mode 100644 index 0000000..c96f814 --- /dev/null +++ b/lib/novacloud_client/objects/solutions/publish_result.rb @@ -0,0 +1,32 @@ +# 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 + 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..8399483 --- /dev/null +++ b/lib/novacloud_client/resources/scheduled_control.rb @@ -0,0 +1,221 @@ +# 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 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") + } + + [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) + return payload unless omit_empty_week_days?(endpoint) + + cleaned = Marshal.load(Marshal.dump(payload)) + 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? + + 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..d348906 --- /dev/null +++ b/lib/novacloud_client/resources/solutions.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +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 + 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 + + # 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 + + def validate_presence!(value, field) + return unless value.nil? || (value.respond_to?(:empty?) && value.empty?) + + 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/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/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/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..e3cc788 --- /dev/null +++ b/spec/novacloud_client/resources/solutions_spec.rb @@ -0,0 +1,499 @@ +# 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 + + 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 + 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"]) + 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 + + 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 "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