diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..f4eefa9 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,13 @@ +Checks: > + -*, + bugprone-*, + modernize-*, + readability-*, + performance-*, + -modernize-use-trailing-return-type, + -readability-magic-numbers, + -readability-identifier-length, + -bugprone-easily-swappable-parameters + +HeaderFilterRegex: '(include|src)/.*\.h$' +WarningsAsErrors: '' diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index e80616f..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,84 +0,0 @@ -# firebolt-cpp-client Copilot Instructions - -Scope: This file applies to the firebolt-cpp-client repository. - -## Primary goals - -- Preserve API contract correctness across interface, implementation, tests, and OpenRPC fixtures. -- Keep generated surfaces and bespoke conventions aligned. -- Prefer minimal, targeted changes. - -## High-signal workflow - -1. For API-facing changes, update all of the following in one pass: - - `include/firebolt/*.h` - - `src/*_impl.h` and `src/*_impl.cpp` - - `test/unit/*Test.cpp` and `test/component/*Test.cpp` - - `docs/openrpc/the-spec/firebolt-open-rpc.json` when component tests depend on fixture shape. -2. Run component tests after edits. -3. If behavior is generator-owned, patch generator code in sibling repo and regenerate module artifacts. - -## Test commands - -- Local one-shot (current preferred): - - `./run-component-tests-local.sh` - - `./run-component-tests-local.sh --skip-image-build` -- Legacy wrappers may still exist in conversation history; prefer the local script in this repo. -- Unit-only: - - `./run-unit-tests.sh` - -## Actions module rules (important) - -- `Actions.intent` is getter-only: - - takes no parameters - - returns `Result` -- `Actions.onIntent` callback payload is a string value. -- Component event trigger for `Actions.onIntent` should use a string JSON payload (for example `"launch"`), not an object. - -## Generated-code conventions that must be preserved - -- `*Impl` classes should delete copy constructor and copy assignment: - - `ClassName(const ClassName&) = delete;` - - `ClassName& operator=(const ClassName&) = delete;` -- Unless explicitly justified as safe, `*Impl` classes should also delete move operations: - - `ClassName(ClassName&&) = delete;` - - `ClassName& operator=(ClassName&&) = delete;` -- Keep include hygiene strict: - - include `` when using `std::move` - - remove unused includes such as `` when not used -- Keep test names in consistent CamelCase for filtering. - -## Component test expectations - -- Red schema validation lines in logs can be expected for negative-path tests. -- Negative tests must still verify runtime behavior (callbacks not delivered for invalid payloads), not just compile-time surface checks. - -## OpenRPC fixture expectations - -- Module descriptions must match actual API behavior. -- Getter-style methods should carry property tags consistent with the rest of the file (for example `property:readonly` where applicable). -- Keep notifier/subscriber metadata aligned (`x-notifier`, `x-subscriber-for`). - -## Regeneration notes (sibling repo) - -When a change is generator-owned, use `firebolt-sdk-gen` and apply module-scoped output back into this repo. - -Typical flow: - -- From `../firebolt-sdk-gen`: - - `./sync-plan-checklist.sh --profile core --module actions --apply --no-accessor-touchpoints --target-root ../firebolt-cpp-client` - -This keeps migration incremental and avoids unrelated accessor touchpoint churn. - -## CI parity reminders - -- CI uses Dockerized build/test flow and mock-firebolt integration. -- Keep changes compatible with: - - `.github/workflows/ci.yml` - - `.github/scripts/run-component-tests.sh` - -## PR hygiene - -- If a review asks for include-file fixes, prefer precise header/source edits and re-run component tests. -- Do not relax negative tests just to suppress red validation logs. -- Keep commit messages scoped and explicit (example: `fix(actions): address include review comments`). diff --git a/.github/instructions/coding-guidelines.instructions.md b/.github/instructions/coding-guidelines.instructions.md new file mode 100644 index 0000000..51d451e --- /dev/null +++ b/.github/instructions/coding-guidelines.instructions.md @@ -0,0 +1,621 @@ +--- +applyTo: "**/*.h,**/*.cpp,**/CMakeLists.txt" +--- + +# firebolt-cpp-client — Coding Guidelines + +**Scope:** This document governs code generation and modification for the `firebolt-cpp-client` repository. +It is intended for use by both AI agents (Copilot, openspec) and human developers. + +**How to read this document:** +- **Current practice** — observed consistently in the codebase; enforce as-is. +- **Recommended going forward** — not yet consistent but must be adopted for new/modified code. +- **Anti-pattern** — seen in the repo OR likely to be introduced by AI generation; includes why it is wrong in this specific codebase. + +Markers: +- `[ASSUMPTION]` — inferred from code patterns where no explicit policy exists. + +--- + +## 1. Architecture and Module Boundaries + +### 1.1 Three-Layer Architecture + +The codebase has exactly three conceptual layers. Do not collapse or skip layers. + +| Layer | Location | Purpose | +|---|---|---| +| Public API | `include/firebolt/.h` | Pure virtual interfaces; the only surface consumers see | +| Implementation | `src/_impl.h` + `src/_impl.cpp` | Concrete logic; hidden from consumers | +| JSON deserialization | `src/json_types/.h` | Wire format ↔ native type adapters; never exposed publicly | + +**Current practice (confirmed across all existing modules):** +- Every module has exactly one interface (`I`), one impl (`Impl`), and zero or more JSON type adapters. Four modules (discovery, localization, network, presentation) have no `json_types/` file; they use primitive-type helpers (`Firebolt::JSON::String`, `Boolean`, `Unsigned`) directly. +- The singleton entry point is `Firebolt::IFireboltAccessor::Instance()`, implemented in `src/firebolt.cpp` as a Meyers singleton over `FireboltAccessorImpl`. + +**Anti-patterns:** +- Do not add business logic to `src/json_types/*.h` files. They must only do: field validation, field extraction, enum mapping. +- Do not add `#include .h>` inside another module's public header unless there is a direct type dependency. Cross-module dependencies at the public layer risk coupling the consumer's include graph. `include/firebolt/metrics.h` correctly includes `"firebolt/common_types.h"` because it uses `AgePolicy`. +- Do not add new public `include/firebolt/` headers for internal types. Internal types live in `src/`. + +### 1.2 Module Registration + +Every new module must be wired into `src/firebolt.cpp`: +1. Member variable of type `Module::ModuleImpl` in `FireboltAccessorImpl`. +2. Initialised via `Firebolt::Helpers::GetHelperInstance()` in the constructor initialiser list. +3. Accessor method returning `Module::IModule&` override. +4. `unsubscribeAll()` call in the private `unsubscribeAll()` helper — **only if the module supports subscriptions** (i.e., the interface exposes any `subscribeOn*` methods). Note: `Device` currently exposes `subscribeOnHdrChanged(...)` but is absent from `FireboltAccessorImpl::unsubscribeAll()` — see Known Issue below. + +Reference: `src/firebolt.cpp` (`FireboltAccessorImpl` ctor initializer list and `unsubscribeAll()`). + +**Known issue:** `FireboltAccessorImpl::unsubscribeAll()` in `src/firebolt.cpp` does not call `device_.unsubscribeAll()` even though `DeviceImpl` supports subscriptions (`subscribeOnHdrChanged`, `subscribeOnDolbyAtmosExperienceAvailableChanged`). Device subscriptions are never cleaned up on Disconnect. Track this as a separate defect. + +### 1.3 API-Facing Change Discipline + +When making an API-facing change — adding or modifying an interface method, changing a return type, or adding a subscription — update all four artifacts in a single commit: +1. `include/firebolt/.h` +2. `src/_impl.h` and `src/_impl.cpp` +3. `test/unit/Test.cpp` and `test/component/Test.cpp` +4. `docs/openrpc/the-spec/firebolt-open-rpc.json` — when component tests depend on fixture shape. + +Never leave the three layers out of sync after a commit. A compile-passing diff that omits a test update or fixture update is incomplete. + +After the change, run `./run-component-tests-local.sh` to validate all layers together before pushing. + +--- + +## 2. Naming Conventions + +### 2.1 Namespaces + +**Current practice (confirmed in every file):** +- Top-level namespace: `Firebolt` +- Module namespace: `Firebolt::` where `` matches the directory/header name with initial capital (e.g., `Firebolt::Device`, `Firebolt::Lifecycle`, `Firebolt::TextToSpeech`). +- JSON adapter namespace: `Firebolt::::JsonData` for module-local types (e.g., `Firebolt::Device::JsonData`, `Firebolt::Accessibility::JsonData`). +- Cross-module shared JSON types: `Firebolt::JsonData` (e.g., `AgePolicyEnum` in `src/json_types/common.h`). +- Every `.h` and `.cpp` file closes with a namespace-end comment: `} // namespace Firebolt::`. + +**Anti-patterns:** +- Do not use `using namespace Firebolt::Helpers;` (or `using namespace Firebolt::;`) in `.cpp` files. Prefer fully-qualified names or targeted `using Firebolt::Helpers::ClassName;` declarations instead. (Applies retroactively to `stats_impl.cpp` and `lifecycle_impl.cpp` — both currently have `using namespace Firebolt::Helpers;` at file scope — flagged for cleanup.) + +### 2.2 Interfaces and Implementations + +**Current practice:** +- Interface: `I` declared in `include/firebolt/.h` (e.g., `IDevice`, `ILifecycle`, `IActions`). +- Implementation: `Impl` in `src/_impl.h` (e.g., `DeviceImpl`, `LifecycleImpl`). +- Neither the interface nor the impl class is named simply `` — that name is reserved for the namespace. + +### 2.3 Method Names + +**Current practice (confirmed across all modules):** +- Getter methods: lowerCamelCase, no `get` prefix (e.g., `chipsetId()`, `audioDescription()`, `connected()`). +- Subscription methods: `subscribeOn()` returning `Result` (e.g., `subscribeOnHdrChanged`, `subscribeOnCountryChanged`, `subscribeOnIntent`). +- Unsubscription: `unsubscribe(SubscriptionId id)` (universal, module-scoped) and `unsubscribeAll()`. +- Invoke-style (fire-and-forget): verb phrases matching the RPC method (e.g., `ready()`, `signIn()`, `close()`). + +**Anti-patterns:** +- Do not name subscription methods `subscribe()` without the `On` prefix — it violates the established naming scheme. +- Do not use `Get`, `Set`, `Is` prefixes on getter methods. + +### 2.4 RPC Method and Event Name Strings + +**Current practice:** +- Getter/invoke RPC name: `"."` in camelCase (e.g., `"Device.chipsetId"`, `"Metrics.startContent"`). +- Event name: `".on"` (e.g., `"Device.onHdrChanged"`, `"Actions.onIntent"`, `"Lifecycle2.onStateChanged"`). +- `Lifecycle2` is the correct wire name for lifecycle RPCs in this version — do not use `Lifecycle`. +- TextToSpeech event names use lowercase suffixes matching the wire protocol (e.g., `"TextToSpeech.onWillspeak"`, `"TextToSpeech.onSpeechstart"`). These must match the OpenRPC fixture exactly. + +**Anti-patterns:** +- Do not guess RPC or event string names. Always derive them from `docs/openrpc/the-spec/firebolt-open-rpc.json`. + +### 2.5 Enum Names + +**Current practice:** +- C++ enum class names: `SCREAMING_SNAKE_CASE` (e.g., `INITIALIZING`, `ACTIVE`, `KILL_RELOAD`). +- `EnumType` instance variable names: `Enum` (e.g., `LifecycleStateEnum`, `CloseReasonEnum`, `DeviceClassEnum`, `AgePolicyEnum`). +- Wire values in `EnumType` map: lowercase strings matching the OpenRPC fixture (e.g., `{"initializing", ...}`, `{"killReload", ...}`). + +### 2.6 JSON Adapter Classes + +**Current practice:** +- Struct adapters: `class : public Firebolt::JSON::NL_Json_Basic<::>` (e.g., `HDRFormat`, `ClosedCaptionsSettings`, `StateChange`). +- Enum adapter instantiation: `inline const Firebolt::JSON::EnumType Enum({{...}})` at namespace scope. + +### 2.7 Test Class Names + +**Current practice (partial inconsistency — see note):** +- Unit test class: `UTest` inheriting `::testing::Test` and `MockBase` (e.g., `DeviceUTest`, `AccessibilityUTest`). +- Component test class: `CTest` inheriting `::testing::Test` (e.g., `DeviceCTest`, `LifecycleCTest`). +- `ActionsGeneratedUTest` in `test/unit/actionsGeneratedTest.cpp` uses a non-standard name because it is auto-generated. This is acceptable only for auto-generated test files. + +**Recommended going forward:** New manually-authored test files must follow the `UTest` / `CTest` naming. + +--- + +## 3. Header Guards and Include Style + +### 3.1 Header Guards + +**Current practice:** +- Bespoke headers (`include/firebolt/` and most `src/`): use `#pragma once`. +- Auto-generated **interface and impl** headers (e.g., `include/firebolt/actions.h`, `src/actions_impl.h`): use `#ifndef FIREBOLT__H` / `#define FIREBOLT__H` / `#endif` guards — confirmed in `include/firebolt/actions.h` and `src/actions_impl.h`. +- Auto-generated **json_types** headers (e.g., `src/json_types/actions.h`): use `#pragma once` — consistent with all bespoke json_types headers. +- Do not mix both guards in the same file. + +**Recommended going forward:** All new bespoke headers use `#pragma once` exclusively. New auto-generated interface/impl headers follow the `#ifndef`/`#define`/`#endif` pattern; new auto-generated json_types headers follow `#pragma once`. + +### 3.2 Include Style + +**Current practice (confirmed across all existing modules):** +- Includes of public firebolt headers: angle brackets with full path (`#include `, `#include `). +- Includes of implementation-local headers: double quotes without path prefix (`#include "device_impl.h"`, `#include "json_types/device.h"`). +- Includes of the module's own public header from within `_impl.h`: double quotes with full path (`#include "firebolt/device.h"`). + +**Anti-pattern:** Do not `#include ` in `include/firebolt/*.h` public headers. Helpers are an internal abstraction not part of the public API. Consumers must never see `IHelper`. + +### 3.3 Include Hygiene + +**Current practice:** +- Include `` when using `std::move`. +- Remove unused includes such as `` when not used. + +Confirmed observation: `include/firebolt/actions.h` (auto-generated) includes `` at line 30 because it uses `std::move` in the `subscribeOnIntentChanged` default method body. + +--- + +## 4. Class Structure and Copy/Move Semantics + +### 4.1 `*Impl` Class Declaration Order + +**Recommended going forward:** +``` +class Impl : public I +{ +public: + explicit Impl(Firebolt::Helpers::IHelper& helper); + Impl(const Impl&) = delete; + Impl& operator=(const Impl&) = delete; + Impl(Impl&&) = delete; + Impl& operator=(Impl&&) = delete; + ~Impl() override = default; // or override with body when custom cleanup needed + + // method overrides + +private: + Firebolt::Helpers::IHelper& helper_; + Firebolt::Helpers::SubscriptionManager subscriptionManager_; // only if module supports subscriptions +}; +``` + +- All `*Impl` constructors take `Firebolt::Helpers::IHelper&` as their only parameter and must be marked `explicit`. Three impl classes currently lack `explicit` — flagged for cleanup: + - `src/stats_impl.h` — `StatsImpl(Firebolt::Helpers::IHelper&)` + - `src/lifecycle_impl.h` — `LifecycleImpl(Firebolt::Helpers::IHelper&)` + - `src/localization_impl.h` — `LocalizationImpl(Firebolt::Helpers::IHelper&)` +- Method return types and parameter types in `*_impl.h` must exactly match those declared in the corresponding public interface header. Always use `uint32_t` (from ``), never the non-standard POSIX type `u_int32_t`. A type mismatch between interface and override produces an invalid override and fails to compile on non-POSIX targets. + + **Cleanup to fix:** `src/device_impl.h` — `timeInActiveState()` is declared as `u_int32_t`; must be changed to `uint32_t` to match `include/firebolt/device.h`. + +### 4.2 Deleted Copy Operations + +**Current practice:** Copy constructor and copy assignment operator are explicitly deleted in **all** 13 `*Impl` classes and in `FireboltAccessorImpl` in `src/firebolt.cpp`. This is mandatory. + +### 4.3 Move Operations + +All `*Impl` classes and `FireboltAccessorImpl` must explicitly delete the move constructor and move assignment operator, placed immediately after the deleted copy operations: + +```cpp +ClassName(ClassName&&) = delete; +ClassName& operator=(ClassName&&) = delete; +``` + +**Rationale:** These classes hold a non-reassignable reference member (`helper_`) and, where applicable, a `SubscriptionManager`. A compiler-generated move would leave the source object with a dangling reference or corrupted subscription state. Explicit deletion makes the intent clear and prevents accidental moves at call sites. + +**Cleanup to fix:** No `*Impl` class currently deletes move operations. The following files must be updated: +- `src/accessibility_impl.h` +- `src/actions_impl.h` +- `src/advertising_impl.h` +- `src/device_impl.h` +- `src/discovery_impl.h` +- `src/display_impl.h` +- `src/lifecycle_impl.h` +- `src/localization_impl.h` +- `src/metrics_impl.h` +- `src/network_impl.h` +- `src/presentation_impl.h` +- `src/stats_impl.h` +- `src/texttospeech_impl.h` +- `src/firebolt.cpp` (`FireboltAccessorImpl`) + +### 4.4 Destructor + +Always declare `~Impl() override = default;`. Do not define a destructor with an empty body `{}` — an empty body is not custom cleanup and must be written as `= default`. Define a body only when it performs actual cleanup work (e.g., releasing a resource not managed by RAII). + +**Cleanup to fix:** +- `src/stats_impl.h` + `src/stats_impl.cpp` — destructor is declared non-inline with an empty body; replace the declaration with `~StatsImpl() override = default;` in the header and remove the definition from the `.cpp` file. +- `src/lifecycle_impl.h` + `src/lifecycle_impl.cpp` — same issue; apply the same fix. + +### 4.5 Friend Declarations + +Do not use `friend` declarations to grant test classes access to implementation internals. Design the public interface to be testable. If internal state genuinely must be observed in a test, use a `protected` member with a test-only subclass. Friend declarations break encapsulation without providing a durable or type-safe test seam, and no other class in this codebase uses this pattern. + +**Cleanup to fix:** `src/lifecycle_impl.h` — `friend class ::LifecycleTest;` is the only `friend` declaration in the codebase and must be removed. + +--- + +## 5. Error Handling + +### 5.1 `Result` at the API Boundary + +**Current practice (without exception across all existing modules):** +- Every method in `I` returns `Result` or `Result`. +- `Result` is used for methods that send a command and carry no return value (e.g., `Metrics.ready()`, `Lifecycle.close()`). +- Callers check the result with boolean conversion (`if (result)`) or dereference after assertion (`*result`). + +**Anti-pattern:** Do not throw exceptions from public interface methods. Do not return bare `T` where failure is possible. Do not use `std::optional` as a substitute for `Result` — `optional` cannot carry an error code. + +### 5.2 Error Propagation in Implementations + +**Current practice:** Implementations propagate errors by returning the result of `helper_.get<>()`, `helper_.invoke()`, or `subscriptionManager_.subscribe<>()` directly. No intermediate `try-catch` is present in any `*_impl.cpp` file. + +**Anti-pattern:** Do not add `try-catch` blocks in `*_impl.cpp` for errors the helper/transport already handles. Do not swallow errors silently. + +### 5.3 Error Handling in JSON Adapters + +**Current practice (confirmed in `src/json_types/`):** +- `fromJson()` throws `std::invalid_argument("Missing required fields in JSON")` when required fields are absent. This is caught by the framework and converted to `Result` with `Error::InvalidParams`. +- `EnumType::at()` throws when an unknown wire value is encountered — also caught by the framework. +- Do not use `Result` inside `fromJson()`. Throw only. + +--- + +## 6. JSON Deserialization Layer (`src/json_types/`) + +### 6.1 JSON Type File Rules + +**Current practice:** +- Each `src/json_types/.h` includes its corresponding `include/firebolt/.h` and ``. +- No `.cpp` file exists under `src/json_types/` — all JSON adapter logic is header-only. +- JSON adapter classes are defined in the `Firebolt::::JsonData` namespace. + +### 6.2 Struct Adapters + +**Current practice:** +```cpp +class : public Firebolt::JSON::NL_Json_Basic<::> +{ +public: + void fromJson(const nlohmann::json& json) override + { + if (!checkRequiredFields(json, {"field1", "field2"})) + { + throw std::invalid_argument("Missing required fields in JSON"); + } + field1_ = json["field1"].get(); + field2_ = json["field2"].get(); + } + :: value() const override + { + return ::{field1_, field2_}; + } +private: + CppType field1_; + CppType field2_; +}; +``` + +Reference: `src/json_types/accessibility.h` (`ClosedCaptionsSettings`, `VoiceGuidanceSettings`), `src/json_types/device.h` (`HDRFormat`), `src/json_types/lifecycle.h` (`StateChange`). + +### 6.3 Enum Adapters + +**Current practice:** +```cpp +inline const Firebolt::JSON::EnumType<::> Enum({ + {"wire-string", ::::ENUMERATOR}, + ... +}); +``` +Wire strings are lowercase or camelCase matching the OpenRPC fixture exactly. + +Reference: `src/json_types/lifecycle.h` (`LifecycleStateEnum`, `CloseReasonEnum`), `src/json_types/device.h` (`DeviceClassEnum`), `src/json_types/common.h` (`AgePolicyEnum`). + +### 6.4 Unit Conversion in JSON Adapters + +**Current practice (specific to `Stats` module):** +The wire payload uses `*KiB` field names (e.g., `userMemoryUsedKiB`). The JSON adapter in `src/json_types/stats.h` reads the raw KiB values and the public API returns those values in KiB units. Tests in `test/unit/statsTest.cpp` and `test/component/statsTest.cpp` validate against the fixture's raw KiB values. + +**Anti-pattern:** Do not add unit conversion (e.g., ×1024 for KiB→bytes) inside `fromJson()` without a corresponding change to the public API type, tests, and OpenRPC fixture annotation. + +--- + +## 7. Helper Abstraction Usage + +### 7.1 `IHelper` Injection + +**Current practice:** All `*Impl` constructors accept `Firebolt::Helpers::IHelper&` by reference and store it in `helper_`. This allows the unit test `MockHelper` to be injected without a virtual wrapper on the Impl class itself. + +**Anti-pattern:** Do not accept `IHelper*` (pointer) — the codebase consistently uses references. Do not store a copy of the helper. + +### 7.2 `helper_.get(methodName)` — Getter Methods + +**Current practice:** +- No-parameter getters: `helper_.get("Module.method")` +- Primitive types: use `Firebolt::JSON::String`, `Firebolt::JSON::Boolean`, `Firebolt::JSON::Unsigned`, etc. +- Struct types: use the module's `JsonData` class (e.g., `JsonData::HDRFormat`) +- Array types: use `Firebolt::JSON::NL_Json_Array` (e.g., `Localization.preferredAudioLanguages`) + +Reference: `src/device_impl.cpp`, `src/localization_impl.cpp`. + +### 7.3 `helper_.invoke(methodName, params)` — Fire-and-Forget Methods + +**Current practice:** Used for methods that return `Result`. Parameters are constructed as `nlohmann::json` before the call. Optional parameters are conditionally added. + +Reference: `src/metrics_impl.cpp` (all methods), `src/lifecycle_impl.cpp` (`close()`). + +### 7.4 `subscriptionManager_.subscribe(eventName, notification)` — Subscriptions + +**Current practice:** +- `JsonType` is the JSON adapter class, not the native type. +- `notification` is moved via `std::move()`. +- The `subscriptionManager_` is only present if the module exposes subscription methods. + +Reference: `src/accessibility_impl.cpp`, `src/actions_impl.cpp`, `src/lifecycle_impl.cpp`. + +--- + +## 8. Threading and Async Patterns + +### 8.1 Implementation Files + +**Current practice:** No threading primitives (`std::thread`, `std::mutex`, `std::condition_variable`, `std::atomic`) appear in any `*_impl.cpp` file. All async behaviour is delegated to the transport layer via `IHelper`. + +**Anti-pattern:** Do not introduce thread management in `*Impl` classes. The transport manages its own threading. + +### 8.2 Component Tests + +**Current practice (confirmed in all event-bearing component tests):** +```cpp +class ModuleCTest : public ::testing::Test +{ +protected: + void SetUp() override { eventReceived = false; } + std::condition_variable cv; + std::mutex mtx; + bool eventReceived; +}; +``` +Event delivery uses `cv.wait_for(lock, EventWaitTime, [&] { return eventReceived; })` via `verifyEventReceived()` and `verifyEventNotReceived()` from `test/utils.h`. `EventWaitTime` is `std::chrono::seconds(2)` (defined in `test/utils.cpp`). + +**Current practice — triggering events:** +- String payload: `triggerEvent("Module.onEvent", R"("string_value")")` — note outer double-quotes in JSON +- Object payload: `triggerEvent("Module.onEvent", R"({"field": value})")` +- For `Actions.onIntent`, the payload is a JSON-encoded object: `triggerEvent("Actions.onIntent", R"({"intent":"launch","intentId":1})")`. The callback receives this string and must parse it with `nlohmann::json::parse()`. See §12.4 for the full contract. + +Reference: `test/component/actionsGeneratedTest.cpp`, `test/component/deviceTest.cpp`, `test/component/networkTest.cpp`. + +--- + +## 9. Logging + +**Current practice:** `FIREBOLT_LOG_NOTICE("Client", "Version: %s", Version::String)` appears only in `src/firebolt.cpp` at connection time. No logging macros appear in individual module `*_impl.cpp` files. + +**[ASSUMPTION]** The logging macro originates from the `FireboltTransport` dependency, not from this repo. Individual module implementations intentionally do not log. + +**Anti-pattern:** Do not add `std::cout`, `printf`, or `FIREBOLT_LOG_*` calls to `*_impl.cpp` files. Diagnostic output in component tests uses `std::cout` only — this is test-scoped and acceptable. + +--- + +## 10. Testing Patterns + +### 10.1 Unit Tests + +**Current practice:** +- Location: `test/unit/Test.cpp` +- Uses `MockHelper` (GMock) via `MockBase` from `test/unit/mock_helper.h`. +- Test fixture: `class UTest : public ::testing::Test, protected MockBase`. +- Impl is instantiated directly: `Firebolt::::Impl impl_{mockHelper};` +- OpenRPC fixture is read via `JsonEngine` from `MockBase`. + +**Test case rules (confirmed across all unit test files):** +- Happy path getter: call `mock("Module.method")`, then call impl method, then `ASSERT_TRUE(result)` + value check. +- Negative path (bad wire data): call `mock_with_response("Module.method", )`, then `ASSERT_FALSE(result)`. +- Subscribe test: call `mockSubscribe("Module.onEvent")`, subscribe, assert `ASSERT_TRUE(result)`, then call `unsubscribe` and assert success. +- Enum validation: `validate_enum("EnumName", Firebolt::::JsonData::Enum)` checks the fixture's schema against the code's enum map. + +**Anti-pattern:** Do not test `*Impl` via `IFireboltAccessor::Instance()` in unit tests. Unit tests must isolate the impl with a mock helper, not the full singleton. + +**Exception for auto-generated unit tests:** `test/unit/actionsGeneratedTest.cpp` directly instantiates `::testing::NiceMock` without inheriting `MockBase`, and uses `EXPECT_CALL(mockHelper, getJson(...))` rather than the `mock()` / `mock_with_response()` convenience wrappers. This pattern is generator-owned. Do not replicate it in bespoke test files. + +### 10.2 Component Tests + +**Current practice:** +- Location: `test/component/Test.cpp` +- Uses `Firebolt::IFireboltAccessor::Instance()` directly (live transport connection). +- Test fixture: `class CTest : public ::testing::Test` (no `MockBase`). +- Expected values derived from `jsonEngine.get_value("Module.method")` against the OpenRPC fixture. + +**Event delivery tests:** +1. Subscribe with callback that sets `eventReceived = true` and calls `cv.notify_one()`. +2. Call `triggerEvent(...)`. +3. Call `verifyEventReceived(mtx, cv, eventReceived)`. +4. Unsubscribe with `verifyUnsubscribeResult(result)`. + +**Negative event tests (invalid payload):** +1. Subscribe. +2. Call `triggerEvent(...)` with invalid JSON payload. +3. Call `verifyEventNotReceived(mtx, cv, eventReceived)` — callback must NOT fire. +4. Unsubscribe. + +Reference: `test/component/lifecycleTest.cpp` (`subscribeOnState_JSON_RPC_compliant`). + +**Component test log expectations:** Red schema validation lines in the component test log are expected and normal for negative-path tests — they indicate the transport rejected the invalid payload as intended. Do not treat them as test failures and do not suppress them by weakening the test. + +Negative tests must verify runtime behaviour — specifically that callbacks are not delivered when the payload is invalid. A test that merely asserts the code compiles with an invalid type is insufficient. Always pair with `verifyEventNotReceived`. Do not relax or remove a negative test because it produces red schema validation lines. + +### 10.3 Pairing Rule + +**Current practice (confirmed across all existing modules):** Every module has both a unit test file and a component test file. When adding a new module or method: +- Add unit tests in `test/unit/Test.cpp` +- Add component tests in `test/component/Test.cpp` +- Both test files must cover all public API methods +- Each getter/property method must have at minimum: one happy-path test and one bad-response negative test + +### 10.4 Expected Values from OpenRPC + +**Current practice:** Both unit and component tests derive expected values from `jsonEngine.get_value("Module.method")` (the first example in the OpenRPC fixture). Do not hardcode values that duplicate the fixture unless the value requires a type conversion (e.g., enum comparison using `static_cast`). + +Exception: `test/component/actionsGeneratedTest.cpp` hardcodes `"launch"` (the `intent` field) and `1` (the `intentId` field) from the fixture's `Actions.intent` / `Actions.onIntent` example result — permissible only for auto-generated files. + +--- + +## 11. OpenRPC Fixture Alignment + +### 11.1 Fixture Examples and Enum Alignment + +**Current practice:** +- Fixture location: `docs/openrpc/the-spec/firebolt-open-rpc.json` +- Both unit and component test binaries read this file at runtime (path injected via `UT_OPEN_RPC_FILE` define in `test/CMakeLists.txt`). +- When adding or changing a method, the fixture must be updated to include the method, its parameters schema, and at least one example. +- Enum values in code must match `components.schemas..enum` in the fixture — validated by `validate_enum()`. + +**Rule:** When a component test validates against `jsonEngine.get_value("Module.method")`, the fixture's example value must produce the same result as what the live mock-firebolt instance returns. Keep these in sync. + +### 11.2 Fixture Metadata Rules + +**Module descriptions:** The `description` field for each module and method in the fixture must accurately describe the module's actual API behaviour. Do not copy-paste descriptions from other modules. + +**Property tags:** Getter-style methods must carry a `property:readonly` tag where other getter methods in the same file use this tag. Before adding a new getter method to the fixture, verify the tagging pattern used by the surrounding methods. + +**Notifier/subscriber metadata:** Subscription event entries must keep `x-notifier` and `x-subscriber-for` fields aligned with the corresponding getter or property. Adding a subscription event without updating both fields is a fixture defect. + +--- + +## 12. Auto-Generated vs Bespoke Code + +### 12.1 Auto-Generated File Recognition + +Files with the following banner are owned by the `firebolt-sdk-gen` generator tool — do not modify them directly: +``` +// ============================================================================ +// AUTO-GENERATED by fb-gen — DO NOT EDIT +// ============================================================================ +``` + +Confirmed auto-generated files in the repo: +- `include/firebolt/actions.h` +- `src/actions_impl.h` +- `src/actions_impl.cpp` +- `src/json_types/actions.h` +- `test/unit/actionsGeneratedTest.cpp` +- `test/component/actionsGeneratedTest.cpp` + +### 12.2 Modifying Auto-Generated Output + +When a change is generator-owned, use `firebolt-sdk-gen` from the sibling repo: +```bash +./sync-plan-checklist.sh --profile core --module --apply --no-accessor-touchpoints --target-root ../firebolt-cpp-client +``` +Do not hand-edit auto-generated files. If the generated output has a defect, fix the generator. + +### 12.3 Keeping Bespoke and Generated Files Aligned + +When a new bespoke module is added, ensure it follows the same structure as generated modules (`actions`) so the two styles remain similar enough that the generator could own the bespoke code in the future. + +### 12.4 Actions Module API Contract + +`Actions.intent` is a getter-only method: it takes no parameters and returns `Result`. Do not add parameters to it and do not change its return type. + +The `std::string` returned by `Actions.intent()` is a JSON-serialized object, not a plain scalar. Callers must parse it (confirmed in `test/component/actionsGeneratedTest.cpp`): +```cpp +auto result = accessor.ActionsInterface().intent(); +ASSERT_TRUE(result); +auto parsed = nlohmann::json::parse(*result); +EXPECT_EQ(parsed.at("intent").get(), "launch"); +EXPECT_EQ(parsed.at("intentId").get(), 1); +``` +Do not treat the return value as a plain scalar string. + +The `Actions.onIntent` callback also delivers a JSON-serialized object string. The component event trigger must use a JSON-encoded object payload (confirmed at `test/component/actionsGeneratedTest.cpp:62`): +```cpp +triggerEvent("Actions.onIntent", R"({"intent":"launch","intentId":1})") +``` +The callback receives the full JSON string and must parse it with `nlohmann::json::parse(intent)`. Do not use a plain string payload such as `R"("launch")"`. + +The OpenRPC fixture confirms: both `Actions.intent` and `Actions.onIntent` example results are `{"intent": "launch", "intentId": 1}` (verified in `docs/openrpc/the-spec/firebolt-open-rpc.json`). + +--- + +## 13. CMake and Build + +**Current practice:** +- C++ standard: C++17, required (`CXX_STANDARD 17`, `CXX_STANDARD_REQUIRED YES`). +- Warning flags: `-Wall -Wextra -Wpedantic` are unconditionally applied in `CMakeLists.txt`. +- New `*_impl.cpp` files are picked up automatically via `file(GLOB SOURCES CONFIGURE_DEPENDS *.cpp json_types/*.cpp)` in `src/CMakeLists.txt`. +- New test files are picked up automatically via `file(GLOB UNIT_TESTS CONFIGURE_DEPENDS unit/*Test.cpp)` and `file(GLOB COMPONENT_TESTS CONFIGURE_DEPENDS component/*Test.cpp)`. +- Export macro: `FIREBOLTCLIENT_EXPORT` from the generated `firebolt/client_export.h`. Apply to public symbols in `include/firebolt/firebolt.h`. + +**Anti-pattern:** Do not manually list sources in `src/CMakeLists.txt` — the glob handles this. Do not introduce new `CMakeLists.txt` files inside nested subdirectories under `src/` or `test/` (e.g., `src/json_types/`, `test/unit/`, `test/component/`). The top-level `src/CMakeLists.txt` and `test/CMakeLists.txt` already exist and must not be replaced. + +**Formatting enforced by CI:** `clang-format` with the project's `.clang-format` (LLVM-based, column limit 120, 4-space indent, Allman braces, C++17). Running `git ls-files -- '*.cpp' '*.h' | xargs clang-format --dry-run --Werror` is a required CI check. Do not bypass it. + +**CI compatibility:** All build and source changes must remain compatible with the CI workflow. Do not modify build configuration in a way that passes locally but diverges from the Docker-based environment defined in `.github/workflows/ci.yml` and `.github/scripts/run-component-tests.sh`. + +--- + +## 14. Anti-Patterns Catalogue + +The following patterns are explicitly wrong for this codebase. Each entry notes where the risk originates. + +| # | Anti-Pattern | Why It Is Wrong Here | +|---|---|---| +| AP-1 | Returning `std::optional` instead of `Result` from interface methods | Cannot carry an error code; breaks the uniform error contract used across all modules | +| AP-2 | Throwing exceptions from `*_impl.cpp` method bodies | Consumers expect `Result`; exceptions escape the module boundary unexpectedly | +| AP-3 | Adding `unique_ptr` or `shared_ptr` for module ownership in `FireboltAccessorImpl` | All modules are owned by value in `FireboltAccessorImpl`; smart pointers add indirection with no benefit here | +| AP-4 | Storing `IHelper` by pointer | Consistent reference storage; pointer would allow null and is not the established contract | +| AP-5 | Making `*Impl` classes copyable or movable | They hold a non-owning reference (`helper_`) and a `SubscriptionManager`; copying/moving would silently break subscription ownership tracking | +| AP-6 | Calling `IFireboltAccessor::Instance()` in unit tests | Unit tests must isolate the impl with `MockHelper`; the singleton instantiates real transport | +| AP-7 | Hardcoding JSON field names as magic strings in `*_impl.cpp` | Field names must live in `src/json_types/` only; impl code must not parse JSON directly | +| AP-8 | Adding `nlohmann::json` includes to `include/firebolt/*.h` | Public headers must not expose the JSON library as a transitive dependency | +| AP-9 | Adding logging to `*_impl.cpp` | Logging is intentionally absent in module implementations; all diagnostics go through the transport layer | +| AP-10 | Writing a new module that omits `subscribeOnStateChanged`-style subscription when the OpenRPC spec has `on*` events | Subscriptions are load-bearing API surface; omitting them silently breaks consumer event handling | +| AP-11 | Assuming `Actions.onIntent` uses a plain string trigger payload | The actual payload is a JSON-encoded object: `triggerEvent("Actions.onIntent", R"({"intent":"launch","intentId":1})")` (confirmed at `test/component/actionsGeneratedTest.cpp:62`). Always check each module's component test file and the OpenRPC fixture for the correct payload shape before writing event trigger calls | +| AP-12 | Using `#include ` or other heavyweight headers without a direct use | Unnecessary includes increase compile time and leak transitive dependencies into consumers' include graphs. `-Wall -Wextra -Wpedantic` do not warn on unused includes; keep includes minimal as a discipline, not for warning suppression. `` is the canonical example of a heavyweight header with no use in this codebase | +| AP-13 | Defining a new public header without `#pragma once` | Bespoke headers require `#pragma once`; `#ifndef` guards are reserved for auto-generated output | +| AP-14 | Modifying auto-generated files by hand | Files with `// AUTO-GENERATED by fb-gen — DO NOT EDIT` must be regenerated via the generator tool | + +--- + +## 15. Explicit Assumptions + +The following items are inferred from code patterns where no explicit policy documentation existed. They are treated as policy until contradicted. + +| ID | Assumption | +|---|---| +| A-1 | `FIREBOLT_LOG_*` macros come from `FireboltTransport`. Individual module impls intentionally omit logging — inferred from the absence of any logging in 12 of 13 impl files. | +| A-2 | The `using namespace Firebolt::Helpers;` pattern in `stats_impl.cpp` and `lifecycle_impl.cpp` is incidental rather than policy — inferred from its absence in the other 11 impl files. | +| A-3 | `StatsImpl`'s, `LifecycleImpl`'s, and `LocalizationImpl`'s non-`explicit` constructors are legacy remnants — confirmed at `src/stats_impl.h:30`, `src/lifecycle_impl.h:37`, `src/localization_impl.h:29`. `StatsImpl`'s non-`= default` destructor is also a remnant. All other impls use `explicit` and `= default`. | +| A-4 | Wire names for TextToSpeech events (`onWillspeak`, `onSpeechstart`, etc.) are lowercase-concatenated because the Firebolt protocol lowercases them — inferred from the pattern in `src/texttospeech_impl.cpp` and the absence of a different naming convention for other modules' events. | + +--- + +## 16. Relationship to Existing Policy Files + +This document is the single authoritative source for coding conventions and workflow rules in the `firebolt-cpp-client` repository. It supersedes `.github/copilot-instructions.md`, which has been absorbed into this document and deleted. + +`CONTRIBUTING.md` governs contribution process. This document governs code shape and workflow. + +--- + +## 17. Test Execution Commands + +**Component tests (current preferred, local):** +```bash +./run-component-tests-local.sh +./run-component-tests-local.sh --skip-image-build # reuse existing Docker image +``` + +**Unit tests only:** +```bash +./run-unit-tests.sh +``` + +Always run component tests after any API-facing change. Component tests run in Docker against mock-firebolt and are the authoritative validation gate. diff --git a/.github/scripts/compare_coverage.py b/.github/scripts/compare_coverage.py new file mode 100644 index 0000000..9fb7699 --- /dev/null +++ b/.github/scripts/compare_coverage.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +# Copyright 2026 Comcast Cable Communications Management, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +""" +Coverage comparison script for firebolt-cpp-client. + +Reads unit test and component test coverage results, compares overall line +coverage against the stored baseline from the build-metadata branch, and prints +a summary. +""" + +import argparse +import datetime +import json +import os +import sys +from typing import Optional + + +# Minimum threshold +THRESHOLD = 75.0 + +_GREEN = "\033[32m" +_RED = "\033[31m" +_RESET = "\033[0m" + +_SEP_WIDTH = 64 +_OVERALL_WIDTH = 80 +_SEP = "\u2500" * _SEP_WIDTH +_HEADER = "\u2500\u2500 Coverage Gate Report " + "\u2500" * (_SEP_WIDTH - 24) + + +def _colored(token: str, ok: bool) -> str: + return f"{_GREEN if ok else _RED}{token}{_RESET}" + + +def _fmt_timestamp(ts: str) -> str: + """Convert '2026-05-28T12:00:00Z' -> '2026-05-28 12:00 UTC'.""" + try: + dt = datetime.datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ") + return dt.strftime("%Y-%m-%d %H:%M UTC") + except (ValueError, TypeError): + return ts + + +def _delta_str(current: float, baseline: float) -> str: + delta = current - baseline + sign = "+" if delta >= 0 else "" + return f"{sign}{delta:.2f}%" + + +def _join_names(names: list) -> str: + return names[0] if len(names) == 1 else " and ".join(names) + + +def _suite_analysis(current: Optional[float], baseline: Optional[float]): + """Analyse one test suite. + + Returns (ok, result_str, delta_disp, warn_reason): + ok - True when no advisory issues found. + result_str - Coloured [PASS]/[WARN] token + detail for the table. + delta_disp - String for the Delta column ("N/A" when skipped). + warn_reason - Reason phrase for the summary line; None when ok. + """ + if current is None: + reason = "coverage data missing" + return False, f"{_colored('[WARN]', False)} {reason}", "N/A", reason + + threshold_ok = current >= THRESHOLD + + if baseline is None: + # No baseline stored — threshold check only. + regression_ok = True + detail = "" if threshold_ok else "below threshold" + delta_disp = "N/A" + elif baseline == 0.0: + # A zero baseline is unreliable — skip regression check. + regression_ok = True + base_note = "baseline unreliable (0%) \u00b7 delta skipped" + detail = f"below threshold \u00b7 {base_note}" if not threshold_ok else base_note + delta_disp = "N/A" + else: + regression_ok = current >= baseline + delta_disp = _delta_str(current, baseline) + if threshold_ok and regression_ok: + detail = "" + elif not threshold_ok and not regression_ok: + detail = "below threshold \u00b7 dropped from baseline" + elif not threshold_ok: + detail = "below threshold \u00b7 no baseline regression" + else: + detail = "above threshold but dropped from baseline" + + overall_ok = threshold_ok and regression_ok + token = _colored("[PASS]", True) if overall_ok else _colored("[WARN]", False) + result_str = f"{token} {detail}" if detail else token + warn_reason = detail if not overall_ok else None + return overall_ok, result_str, delta_disp, warn_reason + + +def _build_summary(warn_suites: list) -> str: + """Build a compact summary from WARN suite (name, reason) pairs.""" + if not warn_suites: + return "" + groups: dict = {} + for name, reason in warn_suites: + groups.setdefault(reason, []).append(name) + parts = [f"{_join_names(names)} {reason}" for reason, names in groups.items()] + return ". ".join(parts) + + +# lcov parsing +def parse_lcov_coverage(path: str) -> Optional[float]: + """Return overall line coverage % from an lcov .info file, or None. + + An lcov .info file contains per-source-file records separated by + ``end_of_record``. Each record may include: + LF: — total instrumented lines in that file + LH: — lines executed at least once + + We aggregate across all records to produce a single project-wide %. + Returns None when the file is absent, empty, or contains no line data. + """ + if not path or not os.path.isfile(path): + return None + + total_found = 0 + total_hit = 0 + + try: + with open(path, "r", encoding="utf-8", errors="replace") as fh: + for raw in fh: + line = raw.strip() + if line.startswith("LF:"): + try: + total_found += int(line[3:]) + except ValueError: + pass + elif line.startswith("LH:"): + try: + total_hit += int(line[3:]) + except ValueError: + pass + except OSError as exc: + print(f" WARNING: Could not read {path}: {exc}", file=sys.stderr) + return None + + if total_found == 0: + return None + + return round((total_hit / total_found) * 100.0, 2) + + + +# Baseline loading +def load_baseline(path: str) -> dict: + """Load baseline JSON; return an empty dict on any error.""" + if not path or not os.path.isfile(path): + return {} + try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + if isinstance(data, dict): + return data + print( + f" WARNING: Baseline {path} is not a JSON object (got {type(data).__name__}) — ignoring", + file=sys.stderr, + ) + except (OSError, json.JSONDecodeError, ValueError) as exc: + print(f" WARNING: Could not parse baseline {path}: {exc}", file=sys.stderr) + return {} + + + +def main() -> None: + parser = argparse.ArgumentParser( + description=( + "Compare unit test and component test coverage against " + "the develop baseline. Informational only — always exits 0 and does " + "not block PRs." + ) + ) + parser.add_argument("--baseline", required=True, metavar="PATH", + help="Path to coverage-baseline.json.") + parser.add_argument("--unit", required=False, metavar="PATH", + help="Path to the unit test lcov filtered_coverage.info file.") + parser.add_argument("--component", required=False, metavar="PATH", + help="Path to the component test lcov filtered_coverage.info file.") + parser.add_argument("--output-json", required=False, metavar="PATH", + help="Write {Unit, Component, commit, timestamp} JSON here for baseline update.") + parser.add_argument("--commit", required=False, default="", + help="Commit SHA to embed in --output-json.") + parser.add_argument("--timestamp", required=False, default="", + help="ISO 8601 timestamp to embed in --output-json.") + args = parser.parse_args() + + baseline = load_baseline(args.baseline) + + def _coerce_pct(value: object) -> Optional[float]: + """Coerce a baseline percentage value to float, or None if invalid.""" + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + baseline_unit: Optional[float] = _coerce_pct(baseline.get("Unit")) + baseline_component: Optional[float] = _coerce_pct(baseline.get("Component")) + + unit_coverage = parse_lcov_coverage(args.unit) if args.unit else None + component_coverage = parse_lcov_coverage(args.component) if args.component else None + + # ------------------------------------------------------------------ + # Optional: write extracted numbers for baseline update. + # Skipped (with a warning) when either suite lacks valid coverage data. + # ------------------------------------------------------------------ + if args.output_json: + if unit_coverage is not None and component_coverage is not None: + payload = { + "Unit": unit_coverage, + "Component": component_coverage, + "commit": args.commit or "", + "timestamp": args.timestamp or "", + } + try: + with open(args.output_json, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2) + fh.write("\n") + except OSError as exc: + print(f" WARNING: Could not write {args.output_json}: {exc}", file=sys.stderr) + else: + print( + f" WARNING: --output-json skipped: coverage data incomplete " + f"(Unit={unit_coverage}, Component={component_coverage})", + file=sys.stderr, + ) + + unit_ok, unit_result, unit_delta, unit_reason = _suite_analysis(unit_coverage, baseline_unit) + component_ok, component_result, component_delta, component_reason = _suite_analysis(component_coverage, baseline_component) + + all_ok = unit_ok and component_ok + status_token = _colored("[PASS]", True) if all_ok else _colored("[WARN]", False) + + # Output report + print() + print(_HEADER) + if baseline: + commit = baseline.get("commit", "unknown") + ts = _fmt_timestamp(baseline.get("timestamp", "")) + print(f" Baseline {commit} ({ts})") + else: + print(" Baseline N/A (first-time setup \u2014 regression check skipped)") + print(f" Threshold {THRESHOLD}% | Status {status_token} (informational \u2014 PRs are not blocked)") + print(_SEP) + + # Coverage table + print(f" {'Suite':<12}{'Current':<9}{'Baseline':<10}{'Delta':<10}Result") + for name, current, base, result, delta_disp in [ + ("Unit", unit_coverage, baseline_unit, unit_result, unit_delta), + ("Component", component_coverage, baseline_component, component_result, component_delta), + ]: + cur_str = f"{current:.2f}%" if current is not None else "N/A" + base_str = f"{base:.2f}%" if base is not None else "N/A" + print(f" {name:<12}{cur_str:<9}{base_str:<10}{delta_disp:<10}{result}") + + print(_SEP) + + # Summary + overall bar + warn_suites = [(n, r) for n, r in [("Unit", unit_reason), ("Component", component_reason)] if r] + summary = _build_summary(warn_suites) + if summary: + print(f" {summary}") + + # Notify when one or both suites had no coverage data (artifact absent). + # Missing data is reported as [WARN]; the gate remains informational. + skipped = [n for n, cov in [("Unit", unit_coverage), ("Component", component_coverage)] if cov is None] + if skipped: + print(f" NOTE: {_join_names(skipped)} coverage data absent \u2014 artifact missing or unreadable.") + + # " OVERALL: [PASS/WARN] " = 1 + 9 + 6 + 1 = 17 visible chars + # left + " OVERALL: " + token(6) + " " + right == _OVERALL_WIDTH + _mid = len(" OVERALL: ") + 6 + len(" ") # 17 + left = "\u2500" * ((_OVERALL_WIDTH - _mid) // 2) # 31 + right = "\u2500" * (_OVERALL_WIDTH - _mid - len(left)) # 32 + print(f"{left} OVERALL: {status_token} {right}") + print() + + # Informational only — always exit 0 so PRs are never blocked. + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/compare_coverage_test.py b/.github/scripts/compare_coverage_test.py new file mode 100644 index 0000000..73e956f --- /dev/null +++ b/.github/scripts/compare_coverage_test.py @@ -0,0 +1,905 @@ +#!/usr/bin/env python3 +# Copyright 2026 Comcast Cable Communications Management, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +""" +Tests for compare_coverage.py + +Covers: + - Unit tests for parse_lcov_coverage(), load_baseline(), _suite_analysis() + - Integration tests (subprocess) simulating all gate scenarios listed in the + Coverage Gate implementation spec. + +Workflow-level scenarios (unit_tests fails / component_tests fails / both fail) are handled +by GitHub Actions' implicit success() dependency check on the coverage-gate job +and cannot be tested at the Python script level; they are documented inline. +""" + +import json +import os +import subprocess +import sys +import tempfile +import unittest + +# --------------------------------------------------------------------------- +# Import the module under test +# --------------------------------------------------------------------------- +SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, SCRIPTS_DIR) + +import compare_coverage # noqa: E402 (after sys.path manipulation) + +THRESHOLD = compare_coverage.THRESHOLD # 75.0 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_lcov(lines_found: int, lines_hit: int) -> str: + """Minimal valid lcov .info content with the given LF/LH counts.""" + return ( + "SF:src/fake.cpp\n" + f"LF:{lines_found}\n" + f"LH:{lines_hit}\n" + "end_of_record\n" + ) + + +def _write_lcov(tmp_dir: str, name: str, lines_found: int, lines_hit: int) -> str: + """Write an lcov file and return its absolute path.""" + path = os.path.join(tmp_dir, name) + with open(path, "w") as fh: + fh.write(_make_lcov(lines_found, lines_hit)) + return path + + +def _write_baseline(tmp_dir: str, data: dict, name: str = "baseline.json") -> str: + """Serialise *data* to JSON and return the path.""" + path = os.path.join(tmp_dir, name) + with open(path, "w") as fh: + json.dump(data, fh) + return path + + +def _run_script(*args: str) -> subprocess.CompletedProcess: + """Invoke compare_coverage.py as a subprocess and return the result.""" + cmd = [sys.executable, os.path.join(SCRIPTS_DIR, "compare_coverage.py"), *args] + return subprocess.run(cmd, capture_output=True, text=True) + + +# =========================================================================== +# Unit tests — parse_lcov_coverage() +# =========================================================================== + +class TestParseLcovCoverage(unittest.TestCase): + """Tests for the lcov .info parser.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def tearDown(self): + import shutil + shutil.rmtree(self.tmp, ignore_errors=True) + + def _write(self, name: str, content: str) -> str: + path = os.path.join(self.tmp, name) + with open(path, "w") as fh: + fh.write(content) + return path + + # --- Missing / empty inputs ------------------------------------------------- + + def test_none_path_returns_none(self): + self.assertIsNone(compare_coverage.parse_lcov_coverage(None)) + + def test_empty_path_returns_none(self): + self.assertIsNone(compare_coverage.parse_lcov_coverage("")) + + def test_nonexistent_file_returns_none(self): + self.assertIsNone(compare_coverage.parse_lcov_coverage("/no/such/file.info")) + + def test_empty_file_returns_none(self): + p = self._write("empty.info", "") + self.assertIsNone(compare_coverage.parse_lcov_coverage(p)) + + def test_no_lf_data_returns_none(self): + p = self._write("no_lf.info", "SF:foo.cpp\nend_of_record\n") + self.assertIsNone(compare_coverage.parse_lcov_coverage(p)) + + def test_lf_zero_returns_none(self): + p = self._write("zero_lf.info", "SF:foo.cpp\nLF:0\nLH:0\nend_of_record\n") + self.assertIsNone(compare_coverage.parse_lcov_coverage(p)) + + # --- Basic coverage values -------------------------------------------------- + + def test_100_percent(self): + p = _write_lcov(self.tmp, "full.info", 100, 100) + self.assertEqual(compare_coverage.parse_lcov_coverage(p), 100.0) + + def test_75_percent_exact(self): + p = _write_lcov(self.tmp, "seventy_five.info", 100, 75) + self.assertEqual(compare_coverage.parse_lcov_coverage(p), 75.0) + + def test_zero_percent(self): + p = _write_lcov(self.tmp, "zero_pct.info", 100, 0) + self.assertEqual(compare_coverage.parse_lcov_coverage(p), 0.0) + + def test_partial_coverage(self): + # 150 / 200 = 75.0 % + p = _write_lcov(self.tmp, "partial.info", 200, 150) + self.assertEqual(compare_coverage.parse_lcov_coverage(p), 75.0) + + # --- Multi-record aggregation ----------------------------------------------- + + def test_aggregates_multiple_records(self): + # 80 + 60 = 140 hit out of 200 → 70.0 % + content = ( + "SF:a.cpp\nLF:100\nLH:80\nend_of_record\n" + "SF:b.cpp\nLF:100\nLH:60\nend_of_record\n" + ) + p = self._write("multi.info", content) + self.assertEqual(compare_coverage.parse_lcov_coverage(p), 70.0) + + # --- Malformed data --------------------------------------------------------- + + def test_malformed_lf_ignored_gracefully(self): + # LF with a non-numeric value; total_found stays 0 → None + p = self._write("bad_lf.info", "SF:a.cpp\nLF:abc\nLH:50\nend_of_record\n") + self.assertIsNone(compare_coverage.parse_lcov_coverage(p)) + + def test_malformed_lh_ignored_gracefully(self): + # LH with garbage value; LF is valid, so LF=100, LH=0 → 0.0 % + p = self._write("bad_lh.info", "SF:a.cpp\nLF:100\nLH:xyz\nend_of_record\n") + self.assertEqual(compare_coverage.parse_lcov_coverage(p), 0.0) + + def test_entirely_non_lcov_content(self): + p = self._write("corrupt.info", "THIS IS NOT A VALID LCOV FILE\n") + self.assertIsNone(compare_coverage.parse_lcov_coverage(p)) + + # --- Rounding --------------------------------------------------------------- + + def test_rounds_to_two_decimal_places(self): + # 1/3 ≈ 33.33 % + p = _write_lcov(self.tmp, "third.info", 3, 1) + self.assertEqual(compare_coverage.parse_lcov_coverage(p), 33.33) + + +# =========================================================================== +# Unit tests — load_baseline() +# =========================================================================== + +class TestLoadBaseline(unittest.TestCase): + """Tests for the JSON baseline loader.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def tearDown(self): + import shutil + shutil.rmtree(self.tmp, ignore_errors=True) + + def _write_json(self, name: str, content: str) -> str: + path = os.path.join(self.tmp, name) + with open(path, "w") as fh: + fh.write(content) + return path + + # --- Missing / empty inputs ------------------------------------------------- + + def test_none_returns_empty_dict(self): + self.assertEqual(compare_coverage.load_baseline(None), {}) + + def test_empty_path_returns_empty_dict(self): + self.assertEqual(compare_coverage.load_baseline(""), {}) + + def test_nonexistent_file_returns_empty_dict(self): + self.assertEqual(compare_coverage.load_baseline("/no/such/file.json"), {}) + + # --- Valid JSON ------------------------------------------------------------- + + def test_valid_baseline_with_unit_and_component(self): + p = _write_baseline(self.tmp, {"Unit": 80.0, "Component": 85.0}) + self.assertEqual(compare_coverage.load_baseline(p), {"Unit": 80.0, "Component": 85.0}) + + def test_valid_empty_json_object(self): + p = _write_baseline(self.tmp, {}) + self.assertEqual(compare_coverage.load_baseline(p), {}) + + def test_valid_baseline_extra_keys_preserved(self): + data = {"Unit": 80.0, "Component": 85.0, "commit": "abc123", "timestamp": "2026-01-01"} + p = _write_baseline(self.tmp, data) + self.assertEqual(compare_coverage.load_baseline(p), data) + + # --- Invalid JSON ----------------------------------------------------------- + + def test_malformed_json_returns_empty_dict(self): + p = self._write_json("bad.json", "{not valid json}") + result = compare_coverage.load_baseline(p) + self.assertEqual(result, {}) + + def test_truncated_json_returns_empty_dict(self): + p = self._write_json("truncated.json", '{"Unit": 80') + self.assertEqual(compare_coverage.load_baseline(p), {}) + + def test_empty_file_returns_empty_dict(self): + p = self._write_json("empty.json", "") + self.assertEqual(compare_coverage.load_baseline(p), {}) + + # --- Non-dict JSON ---------------------------------------------------------- + + def test_json_array_returns_empty_dict(self): + import io, contextlib + p = self._write_json("list.json", "[1, 2, 3]") + buf = io.StringIO() + with contextlib.redirect_stderr(buf): + result = compare_coverage.load_baseline(p) + self.assertEqual(result, {}) + self.assertIn("WARNING", buf.getvalue()) + + def test_json_string_returns_empty_dict(self): + import io, contextlib + p = self._write_json("str.json", '"just a string"') + buf = io.StringIO() + with contextlib.redirect_stderr(buf): + result = compare_coverage.load_baseline(p) + self.assertEqual(result, {}) + self.assertIn("WARNING", buf.getvalue()) + + def test_json_number_returns_empty_dict(self): + import io, contextlib + p = self._write_json("num.json", "42") + buf = io.StringIO() + with contextlib.redirect_stderr(buf): + result = compare_coverage.load_baseline(p) + self.assertEqual(result, {}) + self.assertIn("WARNING", buf.getvalue()) + + def test_json_null_returns_empty_dict(self): + p = self._write_json("null.json", "null") + # null is parsed as None, which is not a dict — Warning emitted, empty dict returned + self.assertEqual(compare_coverage.load_baseline(p), {}) + + +# =========================================================================== +# Unit tests — _suite_analysis() +# =========================================================================== + +class TestSuiteAnalysis(unittest.TestCase): + """ + Tests for the core gate analysis function. + + Gate passes (ok=True) when BOTH: + 1. current >= THRESHOLD (75.0) + 2. current >= baseline (regression check) + + SKIP when current is None. + Regression check disabled when baseline is None or 0.0. + """ + + # --- Scenario 1: exceeds both threshold AND baseline → PASS ---------------- + + def test_s1_exceeds_threshold_and_baseline(self): + ok, _, _, reason = compare_coverage._suite_analysis(80.0, 77.0) + self.assertTrue(ok) + self.assertIsNone(reason) + + # --- Scenario 2: meets threshold exactly (75%) AND beats baseline → PASS --- + + def test_s2_meets_threshold_exactly_beats_baseline(self): + ok, _, _, reason = compare_coverage._suite_analysis(75.0, 70.0) + self.assertTrue(ok) + self.assertIsNone(reason) + + # --- Scenario 3: meets baseline exactly, exceeds threshold → PASS ---------- + + def test_s3_meets_baseline_exactly_exceeds_threshold(self): + ok, _, _, reason = compare_coverage._suite_analysis(80.0, 80.0) + self.assertTrue(ok) + self.assertIsNone(reason) + + # --- Scenario 4: meets BOTH exactly (75.0 == threshold == baseline) → PASS - + + def test_s4_meets_both_exactly_at_threshold(self): + ok, _, _, reason = compare_coverage._suite_analysis(75.0, 75.0) + self.assertTrue(ok) + self.assertIsNone(reason) + + # --- Scenario 5: exceeds threshold but BELOW baseline → FAIL (regression) -- + + def test_s5_above_threshold_below_baseline(self): + ok, result, _, reason = compare_coverage._suite_analysis(76.0, 80.0) + self.assertFalse(ok) + self.assertIsNotNone(reason) + self.assertIn("dropped from baseline", reason) + + # --- Scenario 6: below threshold but meets/exceeds baseline → FAIL ---------- + + def test_s6_below_threshold_meets_baseline(self): + ok, _, _, reason = compare_coverage._suite_analysis(74.0, 70.0) + self.assertFalse(ok) + self.assertIsNotNone(reason) + self.assertIn("below threshold", reason) + self.assertNotIn("dropped from baseline", reason) # regression check passed + + def test_s6b_below_threshold_equals_baseline(self): + ok, _, _, reason = compare_coverage._suite_analysis(74.0, 74.0) + self.assertFalse(ok) + self.assertIsNotNone(reason) + self.assertIn("below threshold", reason) + self.assertNotIn("dropped from baseline", reason) # regression check passed + + # --- Scenario 7: fails BOTH conditions → FAIL -------------------------------- + + def test_s7_fails_both_conditions(self): + ok, _, _, reason = compare_coverage._suite_analysis(70.0, 80.0) + self.assertFalse(ok) + self.assertIsNotNone(reason) + self.assertIn("below threshold", reason) + self.assertIn("dropped from baseline", reason) + + # --- Scenario 8: no baseline → threshold-only check ------------------------- + + def test_no_baseline_above_threshold_passes(self): + ok, _, delta, reason = compare_coverage._suite_analysis(80.0, None) + self.assertTrue(ok) + self.assertIsNone(reason) + self.assertEqual(delta, "N/A") + + def test_no_baseline_below_threshold_fails(self): + ok, _, _, reason = compare_coverage._suite_analysis(70.0, None) + self.assertFalse(ok) + self.assertIn("below threshold", reason) + + def test_no_baseline_at_threshold_exactly_passes(self): + ok, _, _, reason = compare_coverage._suite_analysis(75.0, None) + self.assertTrue(ok) + self.assertIsNone(reason) + + # --- SKIP case: no current coverage ----------------------------------------- + + def test_skip_when_current_is_none(self): + ok, result, delta, reason = compare_coverage._suite_analysis(None, 80.0) + self.assertFalse(ok, "Missing coverage data should be treated as WARN") + self.assertIn("coverage data missing", result) + self.assertEqual(delta, "N/A") + self.assertIsNotNone(reason) + self.assertIn("coverage data missing", reason) + + def test_skip_when_both_none(self): + ok, result, delta, reason = compare_coverage._suite_analysis(None, None) + self.assertFalse(ok) + self.assertIn("coverage data missing", result) + + # --- Zero baseline: regression check disabled -------------------------------- + + def test_zero_baseline_above_threshold_passes(self): + ok, _, delta, reason = compare_coverage._suite_analysis(80.0, 0.0) + self.assertTrue(ok) + self.assertIsNone(reason) + self.assertEqual(delta, "N/A", "Delta must be N/A for zero baseline") + + def test_zero_baseline_below_threshold_fails(self): + ok, _, _, reason = compare_coverage._suite_analysis(70.0, 0.0) + self.assertFalse(ok) + self.assertIsNotNone(reason) + self.assertIn("below threshold", reason) + + # --- Delta string correctness ------------------------------------------------ + + def test_delta_positive(self): + _, _, delta, _ = compare_coverage._suite_analysis(80.0, 77.0) + self.assertEqual(delta, "+3.00%") + + def test_delta_negative(self): + _, _, delta, _ = compare_coverage._suite_analysis(76.0, 80.0) + self.assertEqual(delta, "-4.00%") + + def test_delta_zero(self): + _, _, delta, _ = compare_coverage._suite_analysis(80.0, 80.0) + self.assertEqual(delta, "+0.00%") + + def test_delta_na_when_no_baseline(self): + _, _, delta, _ = compare_coverage._suite_analysis(80.0, None) + self.assertEqual(delta, "N/A") + + # --- Boundary: one tick below threshold (74.99 is impossible from lcov, + # but 74.0 covers the just-below case) ---------------------------------- + + def test_just_below_threshold_fails(self): + # 74 / 100 = 74.0 % + ok, _, _, reason = compare_coverage._suite_analysis(74.0, 70.0) + self.assertFalse(ok) + + def test_just_at_threshold_passes(self): + ok, _, _, reason = compare_coverage._suite_analysis(75.0, 70.0) + self.assertTrue(ok) + + +# =========================================================================== +# Integration tests — main() via subprocess +# =========================================================================== + +class TestMainIntegration(unittest.TestCase): + """ + End-to-end simulation of every gate scenario. + + Each test invokes the script as a subprocess (exactly as GitHub Actions + would) and asserts on exit code and stdout/stderr content. + """ + + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def tearDown(self): + import shutil + shutil.rmtree(self.tmp, ignore_errors=True) + + # --- Helpers ---------------------------------------------------------------- + + def _lcov(self, name: str, lf: int, lh: int) -> str: + return _write_lcov(self.tmp, name, lf, lh) + + def _baseline(self, data: dict, name: str = "baseline.json") -> str: + return _write_baseline(self.tmp, data, name) + + def _run(self, *args: str) -> subprocess.CompletedProcess: + return _run_script(*args) + + # =========================================================================== + # SCENARIO 1 — Coverage exceeds both threshold AND baseline + # Expected: Gate PASSES (exit 0), baseline updates + # =========================================================================== + + def test_s1_exceeds_threshold_and_baseline(self): + bl = self._baseline({"Unit": 77.0, "Component": 78.0}) + unit_cov = self._lcov("unit.info", 100, 80) # 80 % + component_cov = self._lcov("component.info", 100, 82) # 82 % + r = self._run("--baseline", bl, "--unit", unit_cov, "--component", component_cov) + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + self.assertIn("[PASS]", r.stdout) + + # =========================================================================== + # SCENARIO 2 — Coverage meets threshold exactly (75%) and meets baseline + # Expected: Gate PASSES (exit 0) + # =========================================================================== + + def test_s2_meets_threshold_exactly_meets_baseline(self): + bl = self._baseline({"Unit": 70.0, "Component": 70.0}) + unit_cov = self._lcov("unit.info", 100, 75) # 75.0 % + component_cov = self._lcov("component.info", 100, 75) # 75.0 % + r = self._run("--baseline", bl, "--unit", unit_cov, "--component", component_cov) + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + self.assertIn("[PASS]", r.stdout) + + # =========================================================================== + # SCENARIO 3 — Meets baseline exactly but exceeds threshold + # Expected: Gate PASSES (exit 0) + # =========================================================================== + + def test_s3_meets_baseline_exactly_exceeds_threshold(self): + bl = self._baseline({"Unit": 80.0, "Component": 80.0}) + unit_cov = self._lcov("unit.info", 100, 80) # 80 % == baseline + component_cov = self._lcov("component.info", 100, 80) # 80 % == baseline + r = self._run("--baseline", bl, "--unit", unit_cov, "--component", component_cov) + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + + # =========================================================================== + # SCENARIO 4 — Meets BOTH exactly (current == threshold == baseline == 75 %) + # Expected: Gate PASSES (exit 0) + # =========================================================================== + + def test_s4_meets_both_exactly(self): + bl = self._baseline({"Unit": 75.0, "Component": 75.0}) + unit_cov = self._lcov("unit.info", 100, 75) + component_cov = self._lcov("component.info", 100, 75) + r = self._run("--baseline", bl, "--unit", unit_cov, "--component", component_cov) + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + + # =========================================================================== + # SCENARIO 5 — Exceeds threshold but falls BELOW baseline (regression) + # Expected: Gate WARNS (exit 0 — informational only), [WARN] shown + # =========================================================================== + + def test_s5_above_threshold_below_baseline(self): + bl = self._baseline({"Unit": 85.0, "Component": 85.0}) + unit_cov = self._lcov("unit.info", 100, 80) # 80 % < 85 % baseline + component_cov = self._lcov("component.info", 100, 80) + r = self._run("--baseline", bl, "--unit", unit_cov, "--component", component_cov) + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + self.assertIn("[WARN]", r.stdout) + self.assertIn("dropped from baseline", r.stdout) + + # =========================================================================== + # SCENARIO 6 — Falls BELOW threshold but meets/exceeds baseline + # Expected: Gate WARNS (exit 0 — informational only), [WARN] shown + # =========================================================================== + + def test_s6_below_threshold_meets_baseline(self): + bl = self._baseline({"Unit": 70.0, "Component": 70.0}) + unit_cov = self._lcov("unit.info", 100, 74) # 74 % < 75 % threshold + component_cov = self._lcov("component.info", 100, 74) + r = self._run("--baseline", bl, "--unit", unit_cov, "--component", component_cov) + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + self.assertIn("[WARN]", r.stdout) + self.assertIn("below threshold", r.stdout) + + # =========================================================================== + # SCENARIO 7 — Fails BOTH conditions (below threshold AND below baseline) + # Expected: Gate WARNS (exit 0 — informational only), [WARN] shown + # =========================================================================== + + def test_s7_fails_both_threshold_and_baseline(self): + bl = self._baseline({"Unit": 85.0, "Component": 85.0}) + unit_cov = self._lcov("unit.info", 100, 70) # 70 % < threshold AND < baseline + component_cov = self._lcov("component.info", 100, 70) + r = self._run("--baseline", bl, "--unit", unit_cov, "--component", component_cov) + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + self.assertIn("below threshold", r.stdout) + self.assertIn("dropped from baseline", r.stdout) + + # =========================================================================== + # SCENARIO 8a — Baseline file is MISSING + # Expected: Graceful fallback; threshold-only check; no crash + # =========================================================================== + + def test_s8_baseline_missing_coverage_above_threshold(self): + unit_cov = self._lcov("unit.info", 100, 80) + component_cov = self._lcov("component.info", 100, 80) + r = self._run( + "--baseline", "/nonexistent/coverage-baseline.json", + "--unit", unit_cov, "--component", component_cov, + ) + # No baseline → regression skipped → threshold pass → exit 0 + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + + def test_s8_baseline_missing_coverage_below_threshold(self): + unit_cov = self._lcov("unit.info", 100, 70) # 70 % < 75 % + component_cov = self._lcov("component.info", 100, 70) + r = self._run( + "--baseline", "/nonexistent/coverage-baseline.json", + "--unit", unit_cov, "--component", component_cov, + ) + # Informational only — exit 0 even below threshold; [WARN] shown + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + self.assertIn("[WARN]", r.stdout) + + # =========================================================================== + # SCENARIO 9 — Baseline file contains invalid / malformed JSON + # Expected: Warning emitted, treated as empty baseline, gate continues + # =========================================================================== + + def test_s9_malformed_json_above_threshold(self): + path = os.path.join(self.tmp, "malformed.json") + with open(path, "w") as fh: + fh.write("{this is not json}") + unit_cov = self._lcov("unit.info", 100, 80) + component_cov = self._lcov("component.info", 100, 80) + r = self._run("--baseline", path, "--unit", unit_cov, "--component", component_cov) + # Warning must appear in stderr + self.assertIn("WARNING", r.stderr) + # Fallback to empty baseline → threshold-only → pass + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + + def test_s9_malformed_json_below_threshold(self): + path = os.path.join(self.tmp, "malformed2.json") + with open(path, "w") as fh: + fh.write("{bad json") + unit_cov = self._lcov("unit.info", 100, 70) + component_cov = self._lcov("component.info", 100, 70) + r = self._run("--baseline", path, "--unit", unit_cov, "--component", component_cov) + self.assertIn("WARNING", r.stderr) + # Informational only — exit 0 even below threshold; [WARN] shown + self.assertEqual(r.returncode, 0) + self.assertIn("[WARN]", r.stdout) + + def test_s9_empty_json_file(self): + path = os.path.join(self.tmp, "empty.json") + with open(path, "w") as fh: + fh.write("") + unit_cov = self._lcov("unit.info", 100, 80) + component_cov = self._lcov("component.info", 100, 80) + r = self._run("--baseline", path, "--unit", unit_cov, "--component", component_cov) + # Empty file → empty dict baseline → threshold-only → pass + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + + def test_s9_non_dict_json(self): + path = os.path.join(self.tmp, "list_json.json") + with open(path, "w") as fh: + json.dump([1, 2, 3], fh) + unit_cov = self._lcov("unit.info", 100, 80) + component_cov = self._lcov("component.info", 100, 80) + r = self._run("--baseline", path, "--unit", unit_cov, "--component", component_cov) + # Non-dict JSON → WARNING emitted, empty dict → threshold-only → pass + self.assertIn("WARNING", r.stderr) + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + + # =========================================================================== + # SCENARIO 10 — unit_tests job fails + # Coverage Gate does NOT trigger (workflow-level behaviour). + # + # GitHub Actions: coverage_gate has `needs: [unit_tests, component_tests]` + # with no custom `if:`. The implicit success() check means coverage_gate + # is SKIPPED whenever unit_tests fails. This cannot be unit-tested here; + # it is enforced by the workflow graph. + # =========================================================================== + + def test_s10_unit_artifacts_absent_component_passes(self): + """ + Simulates the artifact-level effect: unit .info absent (download step + with continue-on-error:true produced no file), component coverage present + and passing. Script-level: Unit is WARN (data missing), Component is PASS. + """ + bl = self._baseline({"Unit": 75.0, "Component": 75.0}) + component_cov = self._lcov("component.info", 100, 80) # Unit omitted intentionally + r = self._run("--baseline", bl, "--component", component_cov) + # Unit WARN (missing) → overall WARN, but exit 0 (informational) + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + self.assertIn("coverage data missing", r.stdout) + self.assertIn("[WARN]", r.stdout) + + # =========================================================================== + # SCENARIO 11 — component_tests job fails (symmetric to scenario 10) + # =========================================================================== + + def test_s11_component_artifacts_absent_unit_passes(self): + bl = self._baseline({"Unit": 75.0, "Component": 75.0}) + unit_cov = self._lcov("unit.info", 100, 80) # Component omitted intentionally + r = self._run("--baseline", bl, "--unit", unit_cov) + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + self.assertIn("coverage data missing", r.stdout) + self.assertIn("[WARN]", r.stdout) + + # =========================================================================== + # SCENARIO 12 — Both unit_tests AND component_tests jobs fail + # Coverage Gate does NOT trigger (workflow-level). At script level, both + # .info files are absent → both SKIP → exit 0 (harmless; gate is already + # blocked at the workflow graph layer before the script is ever called). + # =========================================================================== + + def test_s12_both_artifacts_absent(self): + bl = self._baseline({"Unit": 75.0, "Component": 75.0}) + # Neither --unit nor --component provided + r = self._run("--baseline", bl) + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + # Both rows + summary line show coverage data missing, OVERALL WARN + self.assertGreaterEqual(r.stdout.count("coverage data missing"), 2) + self.assertIn("[WARN]", r.stdout) + self.assertIn("NOTE:", r.stdout) + + # =========================================================================== + # SCENARIO 13 — Coverage Gate step itself throws an unexpected error + # Expected: non-zero exit; error is visible; baseline NOT updated + # (Simulated by passing a completely invalid path for --baseline that + # causes the argument parser or file logic to surface an error.) + # =========================================================================== + + def test_s13_missing_required_baseline_arg(self): + """Invoking the script without --baseline must fail (argparse error).""" + unit_cov = self._lcov("unit.info", 100, 80) + component_cov = self._lcov("component.info", 100, 80) + r = self._run("--unit", unit_cov, "--component", component_cov) + # argparse exits with code 2 on missing required argument + self.assertNotEqual(r.returncode, 0) + self.assertTrue(len(r.stderr) > 0, "Error must appear on stderr") + + # =========================================================================== + # Partial-failure cases: one suite fails, other passes + # =========================================================================== + + def test_only_unit_fails_gate_warns(self): + """Unit below threshold, Component passes → overall [WARN] but exit 0.""" + bl = self._baseline({"Unit": 75.0, "Component": 75.0}) + unit_cov = self._lcov("unit.info", 100, 70) # 70 % ✗ + component_cov = self._lcov("component.info", 100, 80) # 80 % ✓ + r = self._run("--baseline", bl, "--unit", unit_cov, "--component", component_cov) + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + # Component row still shows PASS + self.assertIn("[PASS]", r.stdout) + self.assertIn("[WARN]", r.stdout) + + def test_only_component_fails_gate_warns(self): + """Component below threshold, Unit passes → overall [WARN] but exit 0.""" + bl = self._baseline({"Unit": 75.0, "Component": 75.0}) + unit_cov = self._lcov("unit.info", 100, 80) # 80 % ✓ + component_cov = self._lcov("component.info", 100, 70) # 70 % ✗ + r = self._run("--baseline", bl, "--unit", unit_cov, "--component", component_cov) + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + self.assertIn("[WARN]", r.stdout) + + def test_only_unit_regresses_gate_warns(self): + """Unit regresses below baseline (still above threshold), Component passes → [WARN] exit 0.""" + bl = self._baseline({"Unit": 85.0, "Component": 75.0}) + unit_cov = self._lcov("unit.info", 100, 80) # 80 % < 85 % baseline ✗ + component_cov = self._lcov("component.info", 100, 80) # 80 % >= 75 % baseline ✓ + r = self._run("--baseline", bl, "--unit", unit_cov, "--component", component_cov) + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + self.assertIn("[WARN]", r.stdout) + + # =========================================================================== + # First-time setup: empty baseline {} → threshold-only + # =========================================================================== + + def test_first_time_setup_empty_baseline_passes(self): + bl = self._baseline({}) + unit_cov = self._lcov("unit.info", 100, 80) + component_cov = self._lcov("component.info", 100, 80) + r = self._run("--baseline", bl, "--unit", unit_cov, "--component", component_cov) + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + + def test_first_time_setup_empty_baseline_below_threshold_warns(self): + bl = self._baseline({}) + unit_cov = self._lcov("unit.info", 100, 70) + component_cov = self._lcov("component.info", 100, 70) + r = self._run("--baseline", bl, "--unit", unit_cov, "--component", component_cov) + # Informational only — exit 0 even below threshold; [WARN] shown + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + self.assertIn("[WARN]", r.stdout) + + # =========================================================================== + # Output format validation + # =========================================================================== + + def test_overall_pass_token_in_output(self): + bl = self._baseline({"Unit": 75.0, "Component": 75.0}) + unit_cov = self._lcov("unit.info", 100, 80) + component_cov = self._lcov("component.info", 100, 80) + r = self._run("--baseline", bl, "--unit", unit_cov, "--component", component_cov) + self.assertIn("OVERALL:", r.stdout) + self.assertIn("[PASS]", r.stdout) + + def test_overall_warn_token_in_output(self): + bl = self._baseline({"Unit": 75.0, "Component": 75.0}) + unit_cov = self._lcov("unit.info", 100, 70) + component_cov = self._lcov("component.info", 100, 70) + r = self._run("--baseline", bl, "--unit", unit_cov, "--component", component_cov) + self.assertIn("OVERALL:", r.stdout) + self.assertIn("[WARN]", r.stdout) + # Informational only — always exit 0 + self.assertEqual(r.returncode, 0) + + # =========================================================================== + # --output-json baseline extraction + # =========================================================================== + + def test_output_json_written_when_both_pass(self): + bl = self._baseline({"Unit": 75.0, "Component": 75.0}) + unit_cov = self._lcov("unit.info", 100, 80) + component_cov = self._lcov("component.info", 100, 82) + out = os.path.join(self.tmp, "new-baseline.json") + self._run( + "--baseline", bl, + "--unit", unit_cov, "--component", component_cov, + "--output-json", out, + "--commit", "abc123", + "--timestamp", "2026-01-01T00:00:00Z", + ) + self.assertTrue(os.path.isfile(out), "output-json must be written") + with open(out) as fh: + data = json.load(fh) + self.assertEqual(data["Unit"], 80.0) + self.assertEqual(data["Component"], 82.0) + self.assertEqual(data["commit"], "abc123") + self.assertEqual(data["timestamp"], "2026-01-01T00:00:00Z") + + def test_output_json_written_even_when_gate_fails(self): + """ + --output-json is written as long as coverage data is available, + regardless of gate outcome. The update-baseline step checks + `if [ ! -s new-baseline.json ]` separately. + """ + bl = self._baseline({"Unit": 90.0, "Component": 90.0}) + unit_cov = self._lcov("unit.info", 100, 80) # 80 % < 90 % baseline → WARN + component_cov = self._lcov("component.info", 100, 80) + out = os.path.join(self.tmp, "new-baseline-fail.json") + r = self._run( + "--baseline", bl, + "--unit", unit_cov, "--component", component_cov, + "--output-json", out, + ) + # Informational only — always exit 0 regardless of gate outcome + self.assertEqual(r.returncode, 0) + self.assertTrue(os.path.isfile(out), "output-json written even on gate warning") + + def test_output_json_not_written_when_unit_missing(self): + """When unit .info is absent, --output-json must NOT be written (data incomplete).""" + bl = self._baseline({"Unit": 75.0, "Component": 75.0}) + component_cov = self._lcov("component.info", 100, 82) + out = os.path.join(self.tmp, "new-baseline-no-unit.json") + r = self._run("--baseline", bl, "--component", component_cov, "--output-json", out) + self.assertFalse(os.path.isfile(out), "output-json must NOT be written when unit absent") + self.assertIn("WARNING", r.stderr) + + def test_output_json_not_written_when_component_missing(self): + bl = self._baseline({"Unit": 75.0, "Component": 75.0}) + unit_cov = self._lcov("unit.info", 100, 80) + out = os.path.join(self.tmp, "new-baseline-no-component.json") + r = self._run("--baseline", bl, "--unit", unit_cov, "--output-json", out) + self.assertFalse(os.path.isfile(out), "output-json must NOT be written when component absent") + self.assertIn("WARNING", r.stderr) + + def test_output_json_not_written_when_both_missing(self): + bl = self._baseline({"Unit": 75.0, "Component": 75.0}) + out = os.path.join(self.tmp, "new-baseline-neither.json") + r = self._run("--baseline", bl, "--output-json", out) + self.assertFalse(os.path.isfile(out)) + self.assertIn("WARNING", r.stderr) + + # =========================================================================== + # Baseline coercion: non-float Unit/Component values must not crash the script + # =========================================================================== + + + def test_baseline_string_unit_treated_as_missing(self): + """String value for Unit in baseline JSON → coerced to None → threshold-only.""" + bl = self._baseline({"Unit": "not-a-number", "Component": 75.0}) + unit_cov = self._lcov("unit.info", 100, 80) + component_cov = self._lcov("component.info", 100, 80) + r = self._run("--baseline", bl, "--unit", unit_cov, "--component", component_cov) + # Must not crash; Unit baseline treated as absent → threshold-only → pass + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + + def test_baseline_null_component_treated_as_missing(self): + """null value for Component in baseline JSON → coerced to None → threshold-only.""" + bl = self._baseline({"Unit": 80.0, "Component": None}) + unit_cov = self._lcov("unit.info", 100, 80) + component_cov = self._lcov("component.info", 100, 80) + r = self._run("--baseline", bl, "--unit", unit_cov, "--component", component_cov) + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + + def test_baseline_both_non_float_threshold_only(self): + """Both Unit/Component baseline values invalid → both threshold-only → pass if above 75%.""" + bl = self._baseline({"Unit": "bad", "Component": "bad"}) + unit_cov = self._lcov("unit.info", 100, 80) + component_cov = self._lcov("component.info", 100, 80) + r = self._run("--baseline", bl, "--unit", unit_cov, "--component", component_cov) + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + + def test_baseline_both_non_float_below_threshold_warns(self): + """Both Unit/Component baseline values invalid → threshold-only → [WARN] exit 0 if below 75%.""" + bl = self._baseline({"Unit": "bad", "Component": "bad"}) + unit_cov = self._lcov("unit.info", 100, 70) + component_cov = self._lcov("component.info", 100, 70) + r = self._run("--baseline", bl, "--unit", unit_cov, "--component", component_cov) + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + self.assertIn("[WARN]", r.stdout) + + # =========================================================================== + # _fmt_timestamp: null/non-string timestamp must not crash the report + # =========================================================================== + + + def test_null_timestamp_in_baseline_does_not_crash(self): + """null timestamp value in baseline JSON → TypeError handled → report still runs.""" + bl = self._baseline({"Unit": 80.0, "Component": 80.0, "commit": "abc", "timestamp": None}) + unit_cov = self._lcov("unit.info", 100, 80) + component_cov = self._lcov("component.info", 100, 80) + r = self._run("--baseline", bl, "--unit", unit_cov, "--component", component_cov) + # Must not crash; timestamp renders as fallback; gate passes + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + self.assertIn("OVERALL:", r.stdout) + + def test_integer_timestamp_in_baseline_does_not_crash(self): + """Integer timestamp → TypeError in strptime → handled gracefully.""" + bl = self._baseline({"Unit": 80.0, "Component": 80.0, "commit": "abc", "timestamp": 12345}) + unit_cov = self._lcov("unit.info", 100, 80) + component_cov = self._lcov("component.info", 100, 80) + r = self._run("--baseline", bl, "--unit", unit_cov, "--component", component_cov) + self.assertEqual(r.returncode, 0, msg=r.stdout + r.stderr) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c7d124..9fdcc1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -200,6 +200,7 @@ jobs: --exclude '.*/test/.*\.cpp' \ --decisions \ --medium-threshold 50 --high-threshold 75 \ + --lcov coverage/filtered_coverage.info \ --html-details coverage/index.html \ --cobertura coverage.cobertura.xml \ " @@ -210,6 +211,12 @@ jobs: name: coverage-report path: ${{ github.workspace }}/build/coverage/ + - name: Upload unit test lcov coverage + uses: actions/upload-artifact@v4 + with: + name: coverage-unit + path: ${{ github.workspace }}/build/coverage/filtered_coverage.info + - name: Code Coverage Summary Report uses: irongut/CodeCoverageSummary@v1.3.0 with: @@ -269,6 +276,25 @@ jobs: --app-openrpc /workspace/docs/openrpc/the-spec/firebolt-app-open-rpc.json \ --test-exe /workspace/build/test/ctApp + - name: Generate Coverage Report + run: | + docker run --rm --user "$(id -u):$(id -g)" -v ${{ github.workspace }}:/workspace ${{ needs.build_docker.outputs.image_tag }} \ + bash -c " \ + set -e \ + && cd build \ + && mkdir -p coverage \ + && gcovr -r .. \ + --exclude '.*/test/.*\.h' \ + --exclude '.*/test/.*\.cpp' \ + --lcov coverage/filtered_coverage.info \ + " + + - name: Upload component test lcov coverage + uses: actions/upload-artifact@v4 + with: + name: coverage-component + path: ${{ github.workspace }}/build/coverage/filtered_coverage.info + api_test_app: permissions: contents: read @@ -324,3 +350,172 @@ jobs: --openrpc /workspace/docs/openrpc/the-spec/firebolt-open-rpc.json \ --app-openrpc /workspace/docs/openrpc/the-spec/firebolt-app-open-rpc.json \ --test-exe /workspace/test/api_test_app/build/api-test-app + + coverage_gate: + name: Coverage Gate + needs: [unit_tests, component_tests] + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Run coverage script tests + run: python3 .github/scripts/compare_coverage_test.py + + - name: Fetch baseline from build-metadata branch + # Gracefully handle a missing build-metadata branch (first-time setup) + continue-on-error: true + run: | + set -euo pipefail + if git fetch origin build-metadata 2>/dev/null; then + if git cat-file -e FETCH_HEAD:coverage-baseline.json 2>/dev/null; then + git show FETCH_HEAD:coverage-baseline.json > coverage-baseline.json + echo "Loaded coverage-baseline.json from build-metadata branch" + else + echo "build-metadata branch exists but coverage-baseline.json not found — skipping baseline comparison" + echo "{}" > coverage-baseline.json + fi + else + echo "build-metadata branch not found — absolute threshold check only (first-time setup)" + echo "{}" > coverage-baseline.json + fi + + - name: Download unit test coverage artifact + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: coverage-unit + path: ./unit-coverage + + - name: Download component test coverage artifact + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: coverage-component + path: ./component-coverage + + - name: Compare coverage to baseline + run: | + python3 .github/scripts/compare_coverage.py \ + --baseline coverage-baseline.json \ + --unit ./unit-coverage/filtered_coverage.info \ + --component ./component-coverage/filtered_coverage.info + + update_baseline: + name: Update Coverage Baseline + # Runs on push to develop when both test suites pass. + # Independent of coverage_gate — the gate is informational and must never + # block the baseline from reflecting the actual state of passing tests. + if: >- + github.event_name == 'push' && + github.ref == 'refs/heads/develop' && + needs.unit_tests.result == 'success' && + needs.component_tests.result == 'success' + needs: [unit_tests, component_tests] + runs-on: ubuntu-latest + permissions: + contents: write + actions: read + # Queue concurrent runs; do not cancel in-progress — each merge deserves + # a baseline update and force-push is atomic so queuing is safe. + concurrency: + group: update-baseline-develop + cancel-in-progress: false + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Fetch existing baseline for comparison report + # Best-effort — if the branch or file is absent we compare against nothing. + continue-on-error: true + run: | + set -euo pipefail + if git fetch origin build-metadata 2>/dev/null; then + if git cat-file -e FETCH_HEAD:coverage-baseline.json 2>/dev/null; then + git show FETCH_HEAD:coverage-baseline.json > old-baseline.json + echo "Loaded existing baseline for comparison" + else + echo '{}' > old-baseline.json + fi + else + echo '{}' > old-baseline.json + fi + + - name: Download unit test coverage artifact + uses: actions/download-artifact@v4 + with: + name: coverage-unit + path: ./unit-coverage + + - name: Download component test coverage artifact + uses: actions/download-artifact@v4 + with: + name: coverage-component + path: ./component-coverage + + - name: Extract coverage and write new baseline + id: extract + run: | + set -euo pipefail + BL_ARG="old-baseline.json" + [ -f "$BL_ARG" ] || echo '{}' > "$BL_ARG" + + python3 .github/scripts/compare_coverage.py \ + --baseline "$BL_ARG" \ + --unit ./unit-coverage/filtered_coverage.info \ + --component ./component-coverage/filtered_coverage.info \ + --output-json new-baseline.json \ + --commit "$GITHUB_SHA" \ + --timestamp "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + + if [ ! -s new-baseline.json ]; then + echo "Coverage extraction produced no output — skipping baseline update" + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "New baseline to commit:" + cat new-baseline.json + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Commit and push updated baseline to build-metadata + if: steps.extract.outputs.skip == 'false' + run: | + set -euo pipefail + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + + # Check out the build-metadata branch, or create it as an orphan. + if git fetch origin build-metadata 2>/dev/null; then + git checkout -B build-metadata FETCH_HEAD + else + git checkout --orphan build-metadata + git rm -rf . --quiet 2>/dev/null || true + fi + + cp -f new-baseline.json coverage-baseline.json + + git add coverage-baseline.json + + # Only commit when there is an actual change. + if git diff --cached --quiet; then + echo "Coverage baseline unchanged — no commit needed" + else + MSG=$(printf \ + 'chore: update coverage baseline after develop merge [skip ci]\n\nCommit : %s\nRun ID : %s' \ + "$GITHUB_SHA" "$GITHUB_RUN_ID") + git commit -m "$MSG" + git push --force-with-lease origin build-metadata + echo "Pushed updated baseline to build-metadata" + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cc774a..ece73a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## [0.6.3](https://github.com/rdkcentral/firebolt-cpp-client/compare/0.6.2...v0.6.3) + +### Fixed +- `Actions.intent` and `Actions.onIntent` now correctly handle a JSON object payload (`{"intent":"...","intentId":N}`) sent by the Firebolt backend. Previously the client failed to parse the response because it expected a plain string. + ## [0.6.2](https://github.com/rdkcentral/firebolt-cpp-client/compare/v0.6.1...v0.6.2) ### Fixed diff --git a/README.md b/README.md index 085c0dc..2a388b7 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,17 @@ Before running it, export `DEVICE_SSH_USER`, `DEVICE_SSH_HOST`, and `DEVICE_SSH_ ## Lint -Use `lint.sh` to run local static analysis for C/C++ sources. +Use `lint.sh` to run the same clang-format lint that CI enforces. +It checks tracked `*.cpp` and `*.h` files using: + +- `git ls-files -- '*.cpp' '*.h' | xargs clang-format --dry-run --Werror` Examples: - `./lint.sh` -- `./lint.sh --tidy-only` -- `./lint.sh --tidy-only --fix` -- `./lint.sh --cppcheck-only` +- `./lint.sh --fix` +- `./lint.sh --local` +- `SKIP_DOCKER=1 ./lint.sh` ## Coverity diff --git a/docs/openrpc/openrpc/discovery.json b/docs/openrpc/openrpc/discovery.json index ac06280..bff6a97 100644 --- a/docs/openrpc/openrpc/discovery.json +++ b/docs/openrpc/openrpc/discovery.json @@ -126,7 +126,7 @@ }, { "name": "watchedV2", - "summary": "Notify the platform that content was partially or completely watched, returns whether the notification was accepted", + "summary": "Notify the platform that content was partially or completely watched", "tags": [ { "name": "polymorphic-reducer" @@ -180,9 +180,8 @@ ], "result": { "name": "result", - "summary": "Whether the platform accepted the watched notification", "schema": { - "type": "boolean" + "type": "null" } }, "examples": [ @@ -208,7 +207,7 @@ ], "result": { "name": "result", - "value": true + "value": null } }, { @@ -237,7 +236,7 @@ ], "result": { "name": "result", - "value": true + "value": null } } ] diff --git a/docs/openrpc/openrpc/localization.json b/docs/openrpc/openrpc/localization.json index 632f70f..77ad905 100644 --- a/docs/openrpc/openrpc/localization.json +++ b/docs/openrpc/openrpc/localization.json @@ -118,6 +118,39 @@ } } ] + }, + { + "name": "timeZone", + "tags": [ + { + "name": "property:readonly" + }, + { + "name": "capabilities", + "x-uses": [ + "xrn:firebolt:capability:localization:time-zone" + ] + } + ], + "summary": "Get the IANA timezone of the device.", + "params": [], + "result": { + "name": "timeZone", + "summary": "The device timezone.", + "schema": { + "type": "string" + } + }, + "examples": [ + { + "name": "Default example", + "params": [], + "result": { + "name": "Default Result", + "value": "America/New_York" + } + } + ] } ], "components": { diff --git a/docs/openrpc/openrpc/speech_synthesis.json b/docs/openrpc/openrpc/speech_synthesis.json new file mode 100644 index 0000000..f221912 --- /dev/null +++ b/docs/openrpc/openrpc/speech_synthesis.json @@ -0,0 +1,9 @@ +{ + "openrpc": "1.2.4", + "info": { + "title": "SpeechSynthesis", + "description": "Scaffold for the SpeechSynthesis Firebolt module.", + "version": "0.0.0" + }, + "methods": [] +} \ No newline at end of file diff --git a/docs/openrpc/openrpc/stats.json b/docs/openrpc/openrpc/stats.json index 2b41f70..6b7a917 100644 --- a/docs/openrpc/openrpc/stats.json +++ b/docs/openrpc/openrpc/stats.json @@ -8,7 +8,7 @@ "methods": [ { "name": "memoryUsage", - "summary": "Returns information about container memory usage, in units of 1024 bytes.", + "summary": "Returns information about container memory usage in bytes.", "tags": [ { "name": "capabilities", @@ -32,10 +32,10 @@ "name": "value", "description": "The memory usage information", "value": { - "userMemoryUsedKiB": 123456, - "userMemoryLimitKiB": 789012, - "gpuMemoryUsedKiB": 345678, - "gpuMemoryLimitKiB": 901234 + "userMemoryUsed": 126418944, + "userMemoryLimit": 807948288, + "gpuMemoryUsed": 353974272, + "gpuMemoryLimit": 922863616 } } } @@ -49,28 +49,32 @@ "type": "object", "description": "Describes current and maximum memory usage of the container.", "properties": { - "userMemoryUsedKiB": { + "userMemoryUsed": { "type": "integer", - "description": "User memory currently used in 1024 bytes." + "description": "User memory currently used, in bytes.", + "minimum": 0 }, - "userMemoryLimitKiB": { + "userMemoryLimit": { "type": "integer", - "description": "Maximum user memory available in 1024 bytes." + "description": "Maximum user memory available, in bytes.", + "minimum": 0 }, - "gpuMemoryUsedKiB": { + "gpuMemoryUsed": { "type": "integer", - "description": "GPU memory currently used in 1024 bytes." + "description": "GPU memory currently used, in bytes.", + "minimum": 0 }, - "gpuMemoryLimitKiB": { + "gpuMemoryLimit": { "type": "integer", - "description": "Maximum GPU memory available in 1024 bytes." + "description": "Maximum GPU memory available, in bytes.", + "minimum": 0 } }, "required": [ - "userMemoryUsedKiB", - "userMemoryLimitKiB", - "gpuMemoryUsedKiB", - "gpuMemoryLimitKiB" + "userMemoryUsed", + "userMemoryLimit", + "gpuMemoryUsed", + "gpuMemoryLimit" ] } } diff --git a/docs/openrpc/openrpc/text_to_speech.json b/docs/openrpc/openrpc/text_to_speech.json index 57575b4..3b499f1 100644 --- a/docs/openrpc/openrpc/text_to_speech.json +++ b/docs/openrpc/openrpc/text_to_speech.json @@ -18,6 +18,48 @@ "type": "string" }, "required": true + }, + { + "name": "callSign", + "summary": "Optional call sign for the app making the request", + "schema": { + "type": "string" + } + }, + { + "name": "language", + "summary": "Optional language override as a BCP 47 locale tag", + "schema": { + "type": "string" + } + }, + { + "name": "voice", + "summary": "Optional voice identifier", + "schema": { + "type": "string" + } + }, + { + "name": "volume", + "summary": "Optional volume override", + "schema": { + "type": "string" + } + }, + { + "name": "rate", + "summary": "Optional speech rate override", + "schema": { + "type": "string" + } + }, + { + "name": "pitch", + "summary": "Optional pitch override", + "schema": { + "type": "string" + } } ], "tags": [ @@ -45,6 +87,30 @@ { "name": "text", "value": "I am a text waiting for speech." + }, + { + "name": "callSign", + "value": "AppA" + }, + { + "name": "language", + "value": "en-US" + }, + { + "name": "voice", + "value": "female-1" + }, + { + "name": "volume", + "value": "80" + }, + { + "name": "rate", + "value": "normal" + }, + { + "name": "pitch", + "value": "medium" } ], "result": { diff --git a/docs/openrpc/the-spec/firebolt-app-open-rpc.json b/docs/openrpc/the-spec/firebolt-app-open-rpc.json index b2eff49..7c090c1 100644 --- a/docs/openrpc/the-spec/firebolt-app-open-rpc.json +++ b/docs/openrpc/the-spec/firebolt-app-open-rpc.json @@ -774,6 +774,80 @@ } ] }, + { + "name": "Localization.onTimeZoneChanged", + "tags": [ + { + "name": "notifier", + "x-notifier-for": "Localization.timeZone", + "x-event": "Localization.onTimeZoneChanged" + }, + { + "name": "capabilities", + "x-uses": [ + "xrn:firebolt:capability:localization:time-zone" + ] + } + ], + "summary": "Get the IANA timezone of the device.", + "params": [ + { + "name": "timeZone", + "summary": "The device timezone.", + "schema": { + "type": "string" + } + } + ], + "examples": [ + { + "name": "Default example", + "params": [ + { + "name": "Default Result", + "value": "America/New_York" + } + ] + } + ] + }, + { + "name": "Device.onDolbyAtmosExperienceAvailableChanged", + "summary": "Returns whether Dolby Atmos experience is available on the device", + "tags": [ + { + "name": "notifier", + "x-notifier-for": "Device.dolbyAtmosExperienceAvailable", + "x-event": "Device.onDolbyAtmosExperienceAvailableChanged" + }, + { + "name": "capabilities", + "x-uses": [ + "xrn:firebolt:capability:device:info" + ] + } + ], + "params": [ + { + "name": "dolbyAtmosExperienceAvailable", + "summary": "Whether Dolby Atmos experience is available on the device", + "schema": { + "type": "boolean" + } + } + ], + "examples": [ + { + "name": "Getting Dolby Atmos experience availability", + "params": [ + { + "name": "dolbyAtmosExperienceAvailable", + "value": true + } + ] + } + ] + }, { "name": "Network.onConnectedChanged", "summary": "Returns whether the device currently has a usable network connection.", @@ -1009,28 +1083,32 @@ "type": "object", "description": "Describes current and maximum memory usage of the container.", "properties": { - "userMemoryUsedKiB": { + "userMemoryUsed": { "type": "integer", - "description": "User memory currently used in 1024 bytes." + "description": "User memory currently used, in bytes.", + "minimum": 0 }, - "userMemoryLimitKiB": { + "userMemoryLimit": { "type": "integer", - "description": "Maximum user memory available in 1024 bytes." + "description": "Maximum user memory available, in bytes.", + "minimum": 0 }, - "gpuMemoryUsedKiB": { + "gpuMemoryUsed": { "type": "integer", - "description": "GPU memory currently used in 1024 bytes." + "description": "GPU memory currently used, in bytes.", + "minimum": 0 }, - "gpuMemoryLimitKiB": { + "gpuMemoryLimit": { "type": "integer", - "description": "Maximum GPU memory available in 1024 bytes." + "description": "Maximum GPU memory available, in bytes.", + "minimum": 0 } }, "required": [ - "userMemoryUsedKiB", - "userMemoryLimitKiB", - "gpuMemoryUsedKiB", - "gpuMemoryLimitKiB" + "userMemoryUsed", + "userMemoryLimit", + "gpuMemoryUsed", + "gpuMemoryLimit" ] }, "TTSEnabled": { diff --git a/docs/openrpc/the-spec/firebolt-open-rpc--legacy.json b/docs/openrpc/the-spec/firebolt-open-rpc--legacy.json index ea3b2d0..a7cf042 100644 --- a/docs/openrpc/the-spec/firebolt-open-rpc--legacy.json +++ b/docs/openrpc/the-spec/firebolt-open-rpc--legacy.json @@ -752,6 +752,97 @@ } ] }, + { + "name": "Device.dolbyAtmosExperienceAvailable", + "summary": "Returns whether Dolby Atmos experience is available on the device", + "params": [], + "tags": [ + { + "name": "property:readonly" + }, + { + "name": "capabilities", + "x-uses": [ + "xrn:firebolt:capability:device:info" + ] + } + ], + "result": { + "name": "dolbyAtmosExperienceAvailable", + "summary": "Whether Dolby Atmos experience is available on the device", + "schema": { + "type": "boolean" + } + }, + "examples": [ + { + "name": "Getting Dolby Atmos experience availability", + "params": [], + "result": { + "name": "Default Result", + "value": true + } + } + ] + }, + { + "name": "Device.onDolbyAtmosExperienceAvailableChanged", + "summary": "Returns whether Dolby Atmos experience is available on the device", + "params": [ + { + "name": "listen", + "required": true, + "schema": { + "type": "boolean" + } + } + ], + "tags": [ + { + "name": "subscriber", + "x-subscriber-for": "Device.dolbyAtmosExperienceAvailable" + }, + { + "name": "event", + "x-alternative": "dolbyAtmosExperienceAvailable" + }, + { + "name": "capabilities", + "x-uses": [ + "xrn:firebolt:capability:device:info" + ] + } + ], + "result": { + "name": "dolbyAtmosExperienceAvailable", + "summary": "Whether Dolby Atmos experience is available on the device", + "schema": { + "anyOf": [ + { + "$ref": "#/x-schemas/Types/ListenResponse" + }, + { + "type": "boolean" + } + ] + } + }, + "examples": [ + { + "name": "Getting Dolby Atmos experience availability", + "params": [ + { + "name": "listen", + "value": true + } + ], + "result": { + "name": "Default Result", + "value": true + } + } + ] + }, { "name": "Discovery.watched", "summary": "Notify the platform that content was partially or completely watched", @@ -2707,7 +2798,7 @@ }, { "name": "Stats.memoryUsage", - "summary": "Returns information about container memory usage, in units of 1024 bytes.", + "summary": "Returns information about container memory usage in bytes.", "tags": [ { "name": "capabilities", @@ -2731,10 +2822,10 @@ "name": "value", "description": "The memory usage information", "value": { - "userMemoryUsedKiB": 123456, - "userMemoryLimitKiB": 789012, - "gpuMemoryUsedKiB": 345678, - "gpuMemoryLimitKiB": 901234 + "userMemoryUsed": 126418944, + "userMemoryLimit": 807948288, + "gpuMemoryUsed": 353974272, + "gpuMemoryLimit": 922863616 } } } @@ -2752,6 +2843,48 @@ "type": "string" }, "required": true + }, + { + "name": "callSign", + "summary": "Optional call sign for the app making the request", + "schema": { + "type": "string" + } + }, + { + "name": "language", + "summary": "Optional language override as a BCP 47 locale tag", + "schema": { + "type": "string" + } + }, + { + "name": "voice", + "summary": "Optional voice identifier", + "schema": { + "type": "string" + } + }, + { + "name": "volume", + "summary": "Optional volume override", + "schema": { + "type": "string" + } + }, + { + "name": "rate", + "summary": "Optional speech rate override", + "schema": { + "type": "string" + } + }, + { + "name": "pitch", + "summary": "Optional pitch override", + "schema": { + "type": "string" + } } ], "tags": [ @@ -2779,6 +2912,30 @@ { "name": "text", "value": "I am a text waiting for speech." + }, + { + "name": "callSign", + "value": "AppA" + }, + { + "name": "language", + "value": "en-US" + }, + { + "name": "voice", + "value": "female-1" + }, + { + "name": "volume", + "value": "80" + }, + { + "name": "rate", + "value": "normal" + }, + { + "name": "pitch", + "value": "medium" } ], "result": { @@ -3530,28 +3687,32 @@ "type": "object", "description": "Describes current and maximum memory usage of the container.", "properties": { - "userMemoryUsedKiB": { + "userMemoryUsed": { "type": "integer", - "description": "User memory currently used in 1024 bytes." + "description": "User memory currently used, in bytes.", + "minimum": 0 }, - "userMemoryLimitKiB": { + "userMemoryLimit": { "type": "integer", - "description": "Maximum user memory available in 1024 bytes." + "description": "Maximum user memory available, in bytes.", + "minimum": 0 }, - "gpuMemoryUsedKiB": { + "gpuMemoryUsed": { "type": "integer", - "description": "GPU memory currently used in 1024 bytes." + "description": "GPU memory currently used, in bytes.", + "minimum": 0 }, - "gpuMemoryLimitKiB": { + "gpuMemoryLimit": { "type": "integer", - "description": "Maximum GPU memory available in 1024 bytes." + "description": "Maximum GPU memory available, in bytes.", + "minimum": 0 } }, "required": [ - "userMemoryUsedKiB", - "userMemoryLimitKiB", - "gpuMemoryUsedKiB", - "gpuMemoryLimitKiB" + "userMemoryUsed", + "userMemoryLimit", + "gpuMemoryUsed", + "gpuMemoryLimit" ] }, "TTSEnabled": { diff --git a/docs/openrpc/the-spec/firebolt-open-rpc.json b/docs/openrpc/the-spec/firebolt-open-rpc.json index 698d4f1..a7a5052 100644 --- a/docs/openrpc/the-spec/firebolt-open-rpc.json +++ b/docs/openrpc/the-spec/firebolt-open-rpc.json @@ -15,6 +15,7 @@ "Metrics": "Methods for sending metrics", "Network": "Methods for accessing network information.", "Presentation": "Methods for accessing Presentation preferences.", + "SpeechSynthesis": "A module for controlling speech synthesis over Firebolt.", "Stats": "Provides methods to retrieve application-level system information.", "TextToSpeech": "A module for controlling and accessing Text To Speech over Firebolt." } @@ -66,9 +67,38 @@ "params": [], "result": { "name": "intent", - "summary": "The current intent.", + "summary": "The current intent as a JSON document.", "schema": { - "type": "string" + "type": "object", + "required": [ + "intent", + "intentId" + ], + "properties": { + "intent": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string" + }, + "context": { + "type": "object", + "properties": { + "source": { + "type": "string" + } + } + } + } + }, + "intentId": { + "type": "integer", + "minimum": 0 + } + } } }, "examples": [ @@ -76,7 +106,15 @@ "name": "Get the current intent", "result": { "name": "Default Result", - "value": "launch" + "value": { + "intent": { + "action": "pre-load", + "context": { + "source": "system" + } + }, + "intentId": 0 + } } } ] @@ -107,9 +145,38 @@ ], "result": { "name": "intent", - "summary": "The current intent.", + "summary": "The current intent as a JSON document.", "schema": { - "type": "string" + "type": "object", + "required": [ + "intent", + "intentId" + ], + "properties": { + "intent": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string" + }, + "context": { + "type": "object", + "properties": { + "source": { + "type": "string" + } + } + } + } + }, + "intentId": { + "type": "integer", + "minimum": 0 + } + } } }, "examples": [ @@ -123,7 +190,87 @@ ], "result": { "name": "Default Result", - "value": "launch" + "value": { + "intent": { + "action": "pre-load", + "context": { + "source": "system" + } + }, + "intentId": 0 + } + } + } + ] + }, + { + "name": "Actions.start", + "summary": "Sends an intent to the platform.", + "tags": [ + { + "name": "capabilities", + "x-uses": [ + "xrn:firebolt:capability:actions:intent" + ] + } + ], + "params": [ + { + "name": "intent", + "summary": "The intent to send, as a JSON document.", + "required": true, + "schema": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string" + }, + "context": { + "type": "object", + "properties": { + "source": { + "type": "string" + } + } + } + } + } + }, + { + "name": "handlerAppId", + "summary": "Optional ID of the application that should handle the intent.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "type": "null" + } + }, + "examples": [ + { + "name": "Start an intent", + "params": [ + { + "name": "intent", + "value": { + "action": "pre-load", + "context": { + "source": "system" + } + } + } + ], + "result": { + "name": "Default Result", + "value": null } } ] @@ -390,6 +537,182 @@ } ] }, + { + "name": "Device.osName", + "summary": "Returns the operating system name as defined by the operator", + "params": [], + "tags": [ + { + "name": "capabilities", + "x-uses": [ + "xrn:firebolt:capability:device:info" + ] + } + ], + "result": { + "name": "osName", + "summary": "The operating system name", + "schema": { + "type": "string" + } + }, + "examples": [ + { + "name": "Getting the operating system name", + "params": [], + "result": { + "name": "Default Result", + "value": "Linux" + } + } + ] + }, + { + "name": "Device.setOsName", + "summary": "Sets the operating system name as defined by the operator", + "tags": [ + { + "name": "capabilities", + "x-uses": [ + "xrn:firebolt:capability:device:info" + ] + } + ], + "params": [ + { + "name": "osName", + "summary": "The operating system name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "type": "null" + } + }, + "examples": [ + { + "name": "Setting the operating system name", + "params": [ + { + "name": "osName", + "value": "Linux" + } + ], + "result": { + "name": "Default Result", + "value": null + } + } + ] + }, + { + "name": "Device.osVersion", + "summary": "Returns the operating system version as defined by the operator", + "params": [], + "tags": [ + { + "name": "capabilities", + "x-uses": [ + "xrn:firebolt:capability:device:info" + ] + } + ], + "result": { + "name": "osVersion", + "summary": "The operating system version", + "schema": { + "type": "string" + } + }, + "examples": [ + { + "name": "Getting the operating system version", + "params": [], + "result": { + "name": "Default Result", + "value": "5.15.0" + } + } + ] + }, + { + "name": "Device.setOsVersion", + "summary": "Sets the operating system version as defined by the operator", + "tags": [ + { + "name": "capabilities", + "x-uses": [ + "xrn:firebolt:capability:device:info" + ] + } + ], + "params": [ + { + "name": "osVersion", + "summary": "The operating system version", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "type": "null" + } + }, + "examples": [ + { + "name": "Setting the operating system version", + "params": [ + { + "name": "osVersion", + "value": "5.15.0" + } + ], + "result": { + "name": "Default Result", + "value": null + } + } + ] + }, + { + "name": "Device.firmware", + "summary": "Returns a string that identifies the firmware image of the device", + "params": [], + "tags": [ + { + "name": "capabilities", + "x-uses": [ + "xrn:firebolt:capability:device:info" + ] + } + ], + "result": { + "name": "firmware", + "summary": "The firmware image string", + "schema": { + "type": "string" + } + }, + "examples": [ + { + "name": "Getting the firmware image", + "params": [], + "result": { + "name": "Default Result", + "value": "1.0.0-20240101" + } + } + ] + }, { "name": "Device.uptime", "summary": "Returns the number of seconds since most recent device boot, including any time spent during deep sleep", @@ -527,6 +850,39 @@ } ] }, + { + "name": "Device.dolbyAtmosExperienceAvailable", + "summary": "Returns whether Dolby Atmos experience is available on the device", + "params": [], + "tags": [ + { + "name": "property:readonly" + }, + { + "name": "capabilities", + "x-uses": [ + "xrn:firebolt:capability:device:info" + ] + } + ], + "result": { + "name": "dolbyAtmosExperienceAvailable", + "summary": "Whether Dolby Atmos experience is available on the device", + "schema": { + "type": "boolean" + } + }, + "examples": [ + { + "name": "Getting Dolby Atmos experience availability", + "params": [], + "result": { + "name": "Default Result", + "value": true + } + } + ] + }, { "name": "Discovery.watched", "summary": "Notify the platform that content was partially or completely watched", @@ -647,7 +1003,7 @@ }, { "name": "Discovery.watchedV2", - "summary": "Notify the platform that content was partially or completely watched, returns whether the notification was accepted", + "summary": "Notify the platform that content was partially or completely watched", "tags": [ { "name": "polymorphic-reducer" @@ -701,9 +1057,8 @@ ], "result": { "name": "result", - "summary": "Whether the platform accepted the watched notification", "schema": { - "type": "boolean" + "type": "null" } }, "examples": [ @@ -729,7 +1084,7 @@ ], "result": { "name": "result", - "value": true + "value": null } }, { @@ -758,7 +1113,7 @@ ], "result": { "name": "result", - "value": true + "value": null } } ] @@ -1088,36 +1443,69 @@ ] }, { - "name": "Metrics.ready", + "name": "Localization.timeZone", "tags": [ + { + "name": "property:readonly" + }, { "name": "capabilities", "x-uses": [ - "xrn:firebolt:capability:metrics:general" + "xrn:firebolt:capability:localization:time-zone" ] } ], - "summary": "Inform the platform that your app is minimally usable. This method is called automatically by `Lifecycle.ready()`", + "summary": "Get the IANA timezone of the device.", "params": [], "result": { - "name": "result", + "name": "timeZone", + "summary": "The device timezone.", "schema": { - "type": "null" + "type": "string" } }, "examples": [ { - "name": "Send ready metric", + "name": "Default example", "params": [], "result": { - "name": "result", - "value": null + "name": "Default Result", + "value": "America/New_York" } } ] }, { - "name": "Metrics.signIn", + "name": "Metrics.ready", + "tags": [ + { + "name": "capabilities", + "x-uses": [ + "xrn:firebolt:capability:metrics:general" + ] + } + ], + "summary": "Inform the platform that your app is minimally usable. This method is called automatically by `Lifecycle.ready()`", + "params": [], + "result": { + "name": "result", + "schema": { + "type": "null" + } + }, + "examples": [ + { + "name": "Send ready metric", + "params": [], + "result": { + "name": "result", + "value": null + } + } + ] + }, + { + "name": "Metrics.signIn", "tags": [ { "name": "rpc-only" @@ -2228,7 +2616,7 @@ }, { "name": "Stats.memoryUsage", - "summary": "Returns information about container memory usage, in units of 1024 bytes.", + "summary": "Returns information about container memory usage in bytes.", "tags": [ { "name": "capabilities", @@ -2252,10 +2640,10 @@ "name": "value", "description": "The memory usage information", "value": { - "userMemoryUsedKiB": 123456, - "userMemoryLimitKiB": 789012, - "gpuMemoryUsedKiB": 345678, - "gpuMemoryLimitKiB": 901234 + "userMemoryUsed": 126418944, + "userMemoryLimit": 807948288, + "gpuMemoryUsed": 353974272, + "gpuMemoryLimit": 922863616 } } } @@ -2273,6 +2661,48 @@ "type": "string" }, "required": true + }, + { + "name": "callSign", + "summary": "Optional call sign for the app making the request", + "schema": { + "type": "string" + } + }, + { + "name": "language", + "summary": "Optional language override as a BCP 47 locale tag", + "schema": { + "type": "string" + } + }, + { + "name": "voice", + "summary": "Optional voice identifier", + "schema": { + "type": "string" + } + }, + { + "name": "volume", + "summary": "Optional volume override", + "schema": { + "type": "string" + } + }, + { + "name": "rate", + "summary": "Optional speech rate override", + "schema": { + "type": "string" + } + }, + { + "name": "pitch", + "summary": "Optional pitch override", + "schema": { + "type": "string" + } } ], "tags": [ @@ -2300,6 +2730,30 @@ { "name": "text", "value": "I am a text waiting for speech." + }, + { + "name": "callSign", + "value": "AppA" + }, + { + "name": "language", + "value": "en-US" + }, + { + "name": "voice", + "value": "female-1" + }, + { + "name": "volume", + "value": "80" + }, + { + "name": "rate", + "value": "normal" + }, + { + "name": "pitch", + "value": "medium" } ], "result": { @@ -2390,185 +2844,552 @@ } ], "result": { - "name": "resumeResult", - "summary": "Result for Resume", + "name": "resumeResult", + "summary": "Result for Resume", + "schema": { + "$ref": "#/components/schemas/TTSStatusResponse" + } + }, + "examples": [ + { + "name": "Resume a given speech id.", + "params": [ + { + "name": "speechid", + "value": 1 + } + ], + "result": { + "name": "TTS_Status", + "value": { + "TTS_Status": 0, + "success": true + } + } + } + ] + }, + { + "name": "TextToSpeech.cancel", + "summary": "Cancels the speech for given speech id", + "description": "Stop speaking if utterance is currently being spoken. Raises onSpeechinterrupted if speaking was interrupted.", + "params": [ + { + "name": "speechid", + "summary": "Identifier for the speech call", + "schema": { + "$ref": "#/components/schemas/SpeechId" + }, + "required": true + } + ], + "tags": [ + { + "name": "rpc-only" + }, + { + "name": "capabilities", + "x-uses": [ + "xrn:firebolt:capability:text-to-speech:general" + ] + } + ], + "result": { + "name": "cancelResult", + "summary": "Result for cancel", + "schema": { + "$ref": "#/components/schemas/TTSStatusResponse" + } + }, + "examples": [ + { + "name": "Cancel a given speech id.", + "params": [ + { + "name": "speechid", + "value": 1 + } + ], + "result": { + "name": "TTS_Status", + "value": { + "TTS_Status": 0, + "success": true + } + } + } + ] + }, + { + "name": "TextToSpeech.getspeechstate", + "summary": "Returns the state of the utterance.", + "params": [ + { + "name": "speechid", + "summary": "Identifier for the speech call", + "schema": { + "$ref": "#/components/schemas/SpeechId" + }, + "required": true + } + ], + "tags": [ + { + "name": "rpc-only" + }, + { + "name": "capabilities", + "x-uses": [ + "xrn:firebolt:capability:text-to-speech:general" + ] + } + ], + "result": { + "name": "speechStateResult", + "summary": "Result for speech state", + "schema": { + "$ref": "#/components/schemas/SpeechStateResponse" + } + }, + "examples": [ + { + "name": "State for a given speech id.", + "params": [ + { + "name": "speechid", + "value": 1 + } + ], + "result": { + "name": "speechstate", + "value": { + "speechstate": 1, + "TTS_Status": 0, + "success": true + } + } + } + ] + }, + { + "name": "TextToSpeech.listvoices", + "summary": "Returns the list of available voices as human-readable strings, e.g. 'ava', 'amelie', 'angelica'", + "params": [ + { + "name": "language", + "summary": "Language - string - BCP 47", + "schema": { + "$ref": "#/x-schemas/Localization/Locale" + }, + "required": true + } + ], + "tags": [ + { + "name": "rpc-only" + }, + { + "name": "capabilities", + "x-uses": [ + "xrn:firebolt:capability:text-to-speech:general" + ] + } + ], + "result": { + "name": "listvoices", + "summary": "The list of voices supported for the language", + "schema": { + "$ref": "#/components/schemas/ListVoicesResponse" + } + }, + "examples": [ + { + "name": "Getting the list of voices", + "params": [ + { + "name": "language", + "value": "en-US" + } + ], + "result": { + "name": "voiceList", + "value": { + "TTS_Status": 0, + "voices": [ + "carol", + "tom" + ] + } + } + } + ] + }, + { + "name": "SpeechSynthesis.speak", + "summary": "Speak an utterance.", + "params": [ + { + "name": "text", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "callSign", + "schema": { + "type": "string" + } + }, + { + "name": "language", + "schema": { + "type": "string" + } + }, + { + "name": "voice", + "schema": { + "type": "string" + } + }, + { + "name": "volume", + "schema": { + "type": "string" + } + }, + { + "name": "rate", + "schema": { + "type": "string" + } + }, + { + "name": "pitch", + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "utteranceId", + "schema": { + "type": "integer", + "minimum": 0 + } + }, + "examples": [ + { + "name": "Speak text", + "params": [ + { + "name": "text", + "value": "I am a text waiting for speech." + }, + { + "name": "callSign", + "value": "AppA" + }, + { + "name": "language", + "value": "en-US" + }, + { + "name": "voice", + "value": "female-1" + }, + { + "name": "volume", + "value": "80" + }, + { + "name": "rate", + "value": "normal" + }, + { + "name": "pitch", + "value": "medium" + } + ], + "result": { + "name": "result", + "value": 1 + } + } + ] + }, + { + "name": "SpeechSynthesis.voices", + "summary": "Get available synthesis voices.", + "params": [], + "result": { + "name": "voices", + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "lang", + "default" + ], + "properties": { + "name": { + "type": "string" + }, + "lang": { + "type": "string" + }, + "default": { + "type": "boolean" + } + } + } + } + }, + "examples": [ + { + "name": "Get voices", + "params": [], + "result": { + "name": "result", + "value": [ + { + "name": "Salli", + "lang": "en-US", + "default": true + } + ] + } + } + ] + }, + { + "name": "SpeechSynthesis.cancel", + "summary": "Cancel an utterance.", + "params": [ + { + "name": "id", + "schema": { + "type": "integer", + "minimum": 0 + }, + "required": true + } + ], + "result": { + "name": "result", "schema": { - "$ref": "#/components/schemas/TTSStatusResponse" + "type": "null" } }, "examples": [ { - "name": "Resume a given speech id.", + "name": "Cancel utterance", "params": [ { - "name": "speechid", + "name": "id", "value": 1 } ], "result": { - "name": "TTS_Status", - "value": { - "TTS_Status": 0, - "success": true - } + "name": "result", + "value": null } } ] }, { - "name": "TextToSpeech.cancel", - "summary": "Cancels the speech for given speech id", - "description": "Stop speaking if utterance is currently being spoken. Raises onSpeechinterrupted if speaking was interrupted.", + "name": "SpeechSynthesis.pause", + "summary": "Pause an utterance.", "params": [ { - "name": "speechid", - "summary": "Identifier for the speech call", + "name": "id", "schema": { - "$ref": "#/components/schemas/SpeechId" + "type": "integer", + "minimum": 0 }, "required": true } ], - "tags": [ - { - "name": "rpc-only" - }, - { - "name": "capabilities", - "x-uses": [ - "xrn:firebolt:capability:text-to-speech:general" - ] - } - ], "result": { - "name": "cancelResult", - "summary": "Result for cancel", + "name": "result", "schema": { - "$ref": "#/components/schemas/TTSStatusResponse" + "type": "null" } }, "examples": [ { - "name": "Cancel a given speech id.", + "name": "Pause utterance", "params": [ { - "name": "speechid", + "name": "id", "value": 1 } ], "result": { - "name": "TTS_Status", - "value": { - "TTS_Status": 0, - "success": true - } + "name": "result", + "value": null } } ] }, { - "name": "TextToSpeech.getspeechstate", - "summary": "Returns the state of the utterance.", + "name": "SpeechSynthesis.resume", + "summary": "Resume an utterance.", "params": [ { - "name": "speechid", - "summary": "Identifier for the speech call", + "name": "id", "schema": { - "$ref": "#/components/schemas/SpeechId" + "type": "integer", + "minimum": 0 }, "required": true } ], - "tags": [ - { - "name": "rpc-only" - }, - { - "name": "capabilities", - "x-uses": [ - "xrn:firebolt:capability:text-to-speech:general" - ] - } - ], "result": { - "name": "speechStateResult", - "summary": "Result for speech state", + "name": "result", "schema": { - "$ref": "#/components/schemas/SpeechStateResponse" + "type": "null" } }, "examples": [ { - "name": "State for a given speech id.", + "name": "Resume utterance", "params": [ { - "name": "speechid", + "name": "id", "value": 1 } ], "result": { - "name": "speechstate", - "value": { - "speechstate": 1, - "TTS_Status": 0, - "success": true - } + "name": "result", + "value": null } } ] }, { - "name": "TextToSpeech.listvoices", - "summary": "Returns the list of available voices as human-readable strings, e.g. 'ava', 'amelie', 'angelica'", + "name": "SpeechSynthesis.onVoicesChanged", + "summary": "Notification when available voices change.", + "tags": [ + { + "name": "event", + "x-notifier": "SpeechSynthesis.onVoicesChanged" + } + ], "params": [ { - "name": "language", - "summary": "Language - string - BCP 47", + "name": "listen", "schema": { - "$ref": "#/x-schemas/Localization/Locale" - }, - "required": true + "type": "boolean" + } } ], - "tags": [ - { - "name": "rpc-only" - }, + "examples": [ { - "name": "capabilities", - "x-uses": [ - "xrn:firebolt:capability:text-to-speech:general" - ] + "name": "Default Example", + "params": [ + { + "name": "listen", + "value": true + } + ], + "result": { + "name": "result", + "value": null + } } ], "result": { - "name": "listvoices", - "summary": "The list of voices supported for the language", + "name": "result", "schema": { - "$ref": "#/components/schemas/ListVoicesResponse" + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "lang", + "default" + ], + "properties": { + "name": { + "type": "string" + }, + "lang": { + "type": "string" + }, + "default": { + "type": "boolean" + } + } + } } - }, + } + }, + { + "name": "SpeechSynthesis.onUtteranceEvent", + "summary": "Notification for utterance lifecycle events.", + "tags": [ + { + "name": "event", + "x-notifier": "SpeechSynthesis.onUtteranceEvent" + } + ], + "params": [ + { + "name": "listen", + "schema": { + "type": "boolean" + } + } + ], "examples": [ { - "name": "Getting the list of voices", + "name": "Default Example", "params": [ { - "name": "language", - "value": "en-US" + "name": "listen", + "value": true } ], "result": { - "name": "voiceList", - "value": { - "TTS_Status": 0, - "voices": [ - "carol", - "tom" + "name": "result", + "value": null + } + } + ], + "result": { + "name": "result", + "schema": { + "type": "object", + "required": [ + "id", + "event" + ], + "properties": { + "id": { + "type": "integer", + "minimum": 0 + }, + "event": { + "type": "string", + "enum": [ + "synthesisStarting", + "playbackStarting", + "paused", + "resumed", + "completed", + "interrupted", + "networkFailed", + "synthesisFailed", + "playbackFailed" ] } } } - ] + } }, { "name": "Lifecycle2.onStateChanged", @@ -3245,6 +4066,52 @@ } } }, + { + "name": "Device.onDolbyAtmosExperienceAvailableChanged", + "summary": "Returns whether Dolby Atmos experience is available on the device", + "params": [ + { + "name": "listen", + "schema": { + "type": "boolean" + } + } + ], + "tags": [ + { + "name": "event", + "x-notifier": "Device.onDolbyAtmosExperienceAvailableChanged", + "x-subscriber-for": "Device.dolbyAtmosExperienceAvailable" + }, + { + "name": "capabilities", + "x-uses": [ + "xrn:firebolt:capability:device:info" + ] + } + ], + "examples": [ + { + "name": "Getting Dolby Atmos experience availability", + "params": [ + { + "name": "listen", + "value": true + } + ], + "result": { + "name": "result", + "value": null + } + } + ], + "result": { + "name": "result", + "schema": { + "type": "null" + } + } + }, { "name": "Localization.onCountryChanged", "tags": [ @@ -3396,6 +4263,52 @@ } } }, + { + "name": "Localization.onTimeZoneChanged", + "tags": [ + { + "name": "event", + "x-notifier": "Localization.onTimeZoneChanged", + "x-subscriber-for": "Localization.timeZone" + }, + { + "name": "capabilities", + "x-uses": [ + "xrn:firebolt:capability:localization:time-zone" + ] + } + ], + "summary": "Get the IANA timezone of the device.", + "params": [ + { + "name": "listen", + "schema": { + "type": "boolean" + } + } + ], + "examples": [ + { + "name": "Default example", + "params": [ + { + "name": "listen", + "value": true + } + ], + "result": { + "name": "result", + "value": null + } + } + ], + "result": { + "name": "result", + "schema": { + "type": "null" + } + } + }, { "name": "Network.onConnectedChanged", "summary": "Returns whether the device currently has a usable network connection.", @@ -3649,28 +4562,32 @@ "type": "object", "description": "Describes current and maximum memory usage of the container.", "properties": { - "userMemoryUsedKiB": { + "userMemoryUsed": { "type": "integer", - "description": "User memory currently used in 1024 bytes." + "description": "User memory currently used, in bytes.", + "minimum": 0 }, - "userMemoryLimitKiB": { + "userMemoryLimit": { "type": "integer", - "description": "Maximum user memory available in 1024 bytes." + "description": "Maximum user memory available, in bytes.", + "minimum": 0 }, - "gpuMemoryUsedKiB": { + "gpuMemoryUsed": { "type": "integer", - "description": "GPU memory currently used in 1024 bytes." + "description": "GPU memory currently used, in bytes.", + "minimum": 0 }, - "gpuMemoryLimitKiB": { + "gpuMemoryLimit": { "type": "integer", - "description": "Maximum GPU memory available in 1024 bytes." + "description": "Maximum GPU memory available, in bytes.", + "minimum": 0 } }, "required": [ - "userMemoryUsedKiB", - "userMemoryLimitKiB", - "gpuMemoryUsedKiB", - "gpuMemoryLimitKiB" + "userMemoryUsed", + "userMemoryLimit", + "gpuMemoryUsed", + "gpuMemoryLimit" ] }, "TTSEnabled": { @@ -4040,4 +4957,4 @@ } } } -} \ No newline at end of file +} diff --git a/fmt.sh b/fmt.sh new file mode 100755 index 0000000..b3ec468 --- /dev/null +++ b/fmt.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Check (default) or fix clang-format. Like `cargo fmt [--check]`. +# ./fmt.sh — check only (exit 1 if violations) +# ./fmt.sh --fix — reformat in place +# +# Uses Docker by default (matches CI exactly). If Docker is unavailable or +# SKIP_DOCKER=1 is set, falls back to the locally installed clang-format. +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Minimal image — only clang-format, matches the Ubuntu version used in CI +IMAGE="firebolt-cpp-client-fmt:local" + +use_docker=true +if [[ "${SKIP_DOCKER:-0}" == "1" ]] || ! command -v docker &>/dev/null; then + use_docker=false +fi + +if [[ "$use_docker" == true ]]; then + if ! docker image inspect "$IMAGE" &>/dev/null; then + echo "Building clang-format Docker image (one-time, ~30s)..." + docker build -t "$IMAGE" - <<'DOCKERFILE' +FROM ubuntu:24.04 +RUN apt-get update && apt-get install -y --no-install-recommends clang-format git && rm -rf /var/lib/apt/lists/* +WORKDIR /workspace +DOCKERFILE + fi + RUN="docker run --rm --user $(id -u):$(id -g) -v $SCRIPT_DIR:/workspace $IMAGE bash -c" +else + if ! command -v clang-format &>/dev/null; then + echo "clang-format not found. Install it or run without SKIP_DOCKER=1." >&2 + exit 1 + fi + echo "[fmt] Using local clang-format ($(clang-format --version))" + RUN="bash -c" +fi + +if [[ "${1:-}" == "--fix" ]]; then + $RUN "set -e && git ls-files -- '*.cpp' '*.h' | xargs clang-format -i" + echo "Done. Files reformatted." +else + $RUN "set -e && git ls-files -- '*.cpp' '*.h' | xargs clang-format --dry-run --Werror" + echo "Formatting OK." +fi diff --git a/include/firebolt/accessibility.h b/include/firebolt/accessibility.h index 810151e..8417591 100644 --- a/include/firebolt/accessibility.h +++ b/include/firebolt/accessibility.h @@ -49,7 +49,7 @@ class IAccessibility * * @retval The audio description setting state or error */ - virtual Result audioDescription() const = 0; + [[nodiscard]] virtual Result audioDescription() const = 0; /** * @brief Subscribe to audio description setting changes @@ -63,7 +63,7 @@ class IAccessibility * * @retval ClosedCaptionsSettings or error */ - virtual Result closedCaptionsSettings() const = 0; + [[nodiscard]] virtual Result closedCaptionsSettings() const = 0; virtual Result subscribeOnClosedCaptionsSettingsChanged(std::function&& notification) = 0; @@ -73,7 +73,7 @@ class IAccessibility * * @retval The high contrast UI setting or error */ - virtual Result highContrastUI() const = 0; + [[nodiscard]] virtual Result highContrastUI() const = 0; virtual Result subscribeOnHighContrastUIChanged(std::function&& notification) = 0; @@ -81,7 +81,7 @@ class IAccessibility * @brief Returns voice guidance settings: enabled, rate, and verbosity * @retval VoiceGuidanceSettings or error */ - virtual Result voiceGuidanceSettings() const = 0; + [[nodiscard]] virtual Result voiceGuidanceSettings() const = 0; virtual Result subscribeOnVoiceGuidanceSettingsChanged(std::function&& notification) = 0; diff --git a/include/firebolt/actions.h b/include/firebolt/actions.h index 660abd6..543320c 100644 --- a/include/firebolt/actions.h +++ b/include/firebolt/actions.h @@ -26,22 +26,37 @@ #include #include #include -#include #include -#include namespace Firebolt::Actions { +struct IntentContext +{ + std::optional source; +}; + +struct IntentData +{ + std::string action; + std::optional context; +}; + +struct Intent +{ + IntentData intent; + uint32_t intentId{0}; +}; + class IActions { public: virtual ~IActions() = default; - virtual Result intent() const = 0; + [[nodiscard]] virtual Result intent() const = 0; - virtual Result subscribeOnIntent(std::function&& notification) = 0; - virtual Result subscribeOnIntentChanged(std::function&& notification) + virtual Result subscribeOnIntent(std::function&& notification) = 0; + virtual Result subscribeOnIntentChanged(std::function&& notification) { return subscribeOnIntent(std::move(notification)); } @@ -49,6 +64,9 @@ class IActions virtual Result unsubscribe(SubscriptionId id) = 0; virtual void unsubscribeAll() = 0; + [[nodiscard]] virtual Result start(const IntentData& intent, + std::optional handlerAppId = std::nullopt) const = 0; + }; // class IActions } // namespace Firebolt::Actions diff --git a/include/firebolt/advertising.h b/include/firebolt/advertising.h index 1a986a9..4a4ab54 100644 --- a/include/firebolt/advertising.h +++ b/include/firebolt/advertising.h @@ -56,6 +56,6 @@ class IAdvertising * @return Ifa struct or error * */ - virtual Result advertisingId() const = 0; + [[nodiscard]] virtual Result advertisingId() const = 0; }; } // namespace Firebolt::Advertising diff --git a/include/firebolt/device.h b/include/firebolt/device.h index 29c3b0a..a5d9481 100644 --- a/include/firebolt/device.h +++ b/include/firebolt/device.h @@ -58,42 +58,77 @@ class IDevice * * @retval The chipset id string or error */ - virtual Result chipsetId() const = 0; + [[nodiscard]] virtual Result chipsetId() const = 0; /** * @brief Get the class of the device * * @retval The class property or error */ - virtual Result deviceClass() const = 0; + [[nodiscard]] virtual Result deviceClass() const = 0; + + /** + * @brief Get the operating system name + * + * @retval The OS name string or error + */ + [[nodiscard]] virtual Result osName() const = 0; + + /** + * @brief Set the operating system name + * + * @retval The status + */ + [[nodiscard]] virtual Result setOsName(const std::string& osName) = 0; + + /** + * @brief Get the operating system version + * + * @retval The OS version string or error + */ + [[nodiscard]] virtual Result osVersion() const = 0; + + /** + * @brief Set the operating system version + * + * @retval The status + */ + [[nodiscard]] virtual Result setOsVersion(const std::string& osVersion) = 0; + + /** + * @brief Get a string that identifies the firmware image of the device + * + * @retval The firmware image string or error + */ + [[nodiscard]] virtual Result firmware() const = 0; /** * @brief Returns the HDR standards that are supported by the attached TV or the integral display * * @retval The HDR format capabilities or error */ - virtual Result hdr() const = 0; + [[nodiscard]] virtual Result hdr() const = 0; /** * @brief Returns number of seconds since most recent device boot, including any time spent during deep sleep * * @retval The uptime in seconds or error */ - virtual Result timeInActiveState() const = 0; + [[nodiscard]] virtual Result timeInActiveState() const = 0; /** * @brief Returns a persistent unique UUID for the current app and device. The UUID is reset when the app or device is reset * * @retval The uid string or error */ - virtual Result uid() const = 0; + [[nodiscard]] virtual Result uid() const = 0; /** * @brief Returns number of seconds since most recent device boot, including any time spent during deep sleep * * @retval The uptime in seconds or error */ - virtual Result uptime() const = 0; + [[nodiscard]] virtual Result uptime() const = 0; /** * @brief Subscribe to HDR format changes @@ -116,6 +151,21 @@ class IDevice * @brief Remove all active subscriptions from subscribers list. */ virtual void unsubscribeAll() = 0; + + /** + * @brief Returns whether Dolby Atmos experience is available on the device + * + * @retval True if Dolby Atmos experience is available, or error + */ + [[nodiscard]] virtual Result dolbyAtmosExperienceAvailable() const = 0; + + /** + * @brief Subscribe to Dolby Atmos experience availability changes + * + * @retval SubscriptionId or error + */ + virtual Result + subscribeOnDolbyAtmosExperienceAvailableChanged(std::function&& notification) = 0; }; } // namespace Firebolt::Device diff --git a/include/firebolt/discovery.h b/include/firebolt/discovery.h index a3e9982..aced324 100644 --- a/include/firebolt/discovery.h +++ b/include/firebolt/discovery.h @@ -40,14 +40,17 @@ class IDiscovery * to which content may be directed * * @retval Whether the platform successfully recorded the watched notification, or an error + * + * @note This method is retained for backward compatibility with the original Discovery spec. + * Prefer watchedV2() for new integrations, which returns Result and omits the + * redundant boolean payload. */ - virtual Result watched(const std::string& entityId, std::optional progress, - std::optional completed, std::optional watchedOn, - std::optional agePolicy) const = 0; + [[nodiscard]] virtual Result watched(const std::string& entityId, std::optional progress, + std::optional completed, std::optional watchedOn, + std::optional agePolicy) const = 0; /** - * @brief Notify the platform that content was partially or completely watched, returns whether the notification - * was accepted + * @brief Notify the platform that content was partially or completely watched * * @param[in] entityId : The entity Id of the watched content * @param[in] progress : How much of the content has been watched (percentage as (0-0.999) for VOD, number of @@ -57,10 +60,10 @@ class IDiscovery * @param[in] agePolicy : The age policy associated with the watch event. The age policy describes the age groups * to which content may be directed * - * @retval Whether the platform accepted the watched notification, or an error + * @retval An ok Result on success, or an error; no value is returned */ - virtual Result watchedV2(const std::string& entityId, std::optional progress, - std::optional completed, std::optional watchedOn, - std::optional agePolicy) const = 0; + [[nodiscard]] virtual Result watchedV2(const std::string& entityId, std::optional progress, + std::optional completed, std::optional watchedOn, + std::optional agePolicy) const = 0; }; } // namespace Firebolt::Discovery diff --git a/include/firebolt/display.h b/include/firebolt/display.h index ac723fe..ac4719d 100644 --- a/include/firebolt/display.h +++ b/include/firebolt/display.h @@ -41,7 +41,7 @@ class IDisplay * * @retval The class property or error */ - virtual Result edid() const = 0; + [[nodiscard]] virtual Result edid() const = 0; /** * @brief Returns the physical/native resolution of the connected or integral display, in pixels @@ -56,7 +56,7 @@ class IDisplay * * @retval The display resolution (width and height in pixels) or error */ - virtual Result maxResolution() const = 0; + [[nodiscard]] virtual Result maxResolution() const = 0; /** * @brief Returns the physical dimensions of the connected or integral display, in centimeters @@ -71,7 +71,7 @@ class IDisplay * * @retval The class property or error */ - virtual Result size() const = 0; + [[nodiscard]] virtual Result size() const = 0; }; } // namespace Firebolt::Display diff --git a/include/firebolt/firebolt.h b/include/firebolt/firebolt.h index d889ec0..acd4399 100644 --- a/include/firebolt/firebolt.h +++ b/include/firebolt/firebolt.h @@ -30,8 +30,10 @@ #include "firebolt/metrics.h" #include "firebolt/network.h" #include "firebolt/presentation.h" +#include "firebolt/speechsynthesis.h" #include "firebolt/stats.h" #include "firebolt/texttospeech.h" +#include "firebolt/videooutput.h" #include #include #include @@ -163,11 +165,20 @@ class FIREBOLTCLIENT_EXPORT IFireboltAccessor */ virtual TextToSpeech::ITextToSpeech& TextToSpeechInterface() = 0; + /** + * @brief Returns instance of SpeechSynthesis interface + * + * @return Reference to SpeechSynthesis interface + */ + virtual SpeechSynthesis::ISpeechSynthesis& SpeechSynthesisInterface() = 0; + /** * @brief Returns instance of Actions interface * * @return Reference to Actions interface */ virtual Actions::IActions& ActionsInterface() = 0; + + virtual VideoOutput::IVideoOutput& VideoOutputInterface() = 0; }; } // namespace Firebolt diff --git a/include/firebolt/lifecycle.h b/include/firebolt/lifecycle.h index 94ed93e..3581cda 100644 --- a/include/firebolt/lifecycle.h +++ b/include/firebolt/lifecycle.h @@ -68,14 +68,14 @@ class ILifecycle * * @param[in] type The type of the close app is requesting */ - virtual Result close(const CloseType& type) const = 0; + [[nodiscard]] virtual Result close(const CloseType& type) const = 0; /** * @brief Get the current lifecycle state of the app * * @retval The current lifecycle state or error */ - virtual Result state() const = 0; + [[nodiscard]] virtual Result state() const = 0; /** * @brief Subscribe to lifecycle state changes diff --git a/include/firebolt/localization.h b/include/firebolt/localization.h index 3eb9dc3..6e33374 100644 --- a/include/firebolt/localization.h +++ b/include/firebolt/localization.h @@ -35,7 +35,7 @@ class ILocalization * * @retval The device country code or error */ - virtual Result country() const = 0; + [[nodiscard]] virtual Result country() const = 0; /** * @brief A list of zero or more languages in order of decreasing preference. Typically two languages are present. @@ -43,14 +43,21 @@ class ILocalization * * @retval The preferred audio languages or error */ - virtual Result> preferredAudioLanguages() const = 0; + [[nodiscard]] virtual Result> preferredAudioLanguages() const = 0; /** * @brief The presentation language of the device, in BCP 47, e.g. en-US * * @retval The preferred audio languages or error */ - virtual Result presentationLanguage() const = 0; + [[nodiscard]] virtual Result presentationLanguage() const = 0; + + /** + * @brief Get the IANA timezone of the device. + * + * @retval The device timezone or error + */ + [[nodiscard]] virtual Result timeZone() const = 0; /** * @brief Subscribe on the change of CountryChanged property @@ -81,6 +88,15 @@ class ILocalization virtual Result subscribeOnPresentationLanguageChanged(std::function&& notification) = 0; + /** + * @brief Subscribe on the change of timeZone property + * + * @param[in] notification : The callback function + * + * @retval The subscriptionId or error + */ + virtual Result subscribeOnTimeZoneChanged(std::function&& notification) = 0; + /** * @brief Remove subscriber from subscribers list. This method is generic for * all subscriptions diff --git a/include/firebolt/metrics.h b/include/firebolt/metrics.h index 6927674..23b8673 100644 --- a/include/firebolt/metrics.h +++ b/include/firebolt/metrics.h @@ -45,21 +45,21 @@ class IMetrics * * @retval An ok Result on success, or an error; no value is returned */ - virtual Result ready() const = 0; + [[nodiscard]] virtual Result ready() const = 0; /** * @brief Logs a sign in event * * @retval An ok Result on success, or an error; no value is returned */ - virtual Result signIn() const = 0; + [[nodiscard]] virtual Result signIn() const = 0; /** * @brief Logs a sign out event * * @retval An ok Result on success, or an error; no value is returned */ - virtual Result signOut() const = 0; + [[nodiscard]] virtual Result signOut() const = 0; /** * @brief Informs the platform that your user has started content @@ -70,8 +70,8 @@ class IMetrics * * @retval An ok Result on success, or an error; no value is returned */ - virtual Result startContent(const std::optional& entityId, - const std::optional agePolicy) const = 0; + [[nodiscard]] virtual Result startContent(const std::optional& entityId, + std::optional agePolicy) const = 0; /** * @brief Informs the platform that your user has stopped content @@ -82,8 +82,8 @@ class IMetrics * * @retval An ok Result on success, or an error; no value is returned */ - virtual Result stopContent(const std::optional& entityId, - const std::optional agePolicy) const = 0; + [[nodiscard]] virtual Result stopContent(const std::optional& entityId, + std::optional agePolicy) const = 0; /** * @brief Informs the platform that your user has navigated to a page or view @@ -94,7 +94,8 @@ class IMetrics * * @retval An ok Result on success, or an error; no value is returned */ - virtual Result page(const std::string& pageId, const std::optional& agePolicy) const = 0; + [[nodiscard]] virtual Result page(const std::string& pageId, + const std::optional& agePolicy) const = 0; /** * @brief Informs the platform of an error that has occurred in your app @@ -109,9 +110,10 @@ class IMetrics * * @retval An ok Result on success, or an error; no value is returned */ - virtual Result error(const ErrorType type, const std::string& code, const std::string& description, - const bool visible, const std::optional>& parameters, - const std::optional& agePolicy) const = 0; + [[nodiscard]] virtual Result error(ErrorType type, const std::string& code, const std::string& description, + bool visible, + const std::optional>& parameters, + const std::optional& agePolicy) const = 0; /** * @brief Called when setting the URL of a media asset to play, in order to infer load time @@ -122,8 +124,8 @@ class IMetrics * * @retval An ok Result on success, or an error; no value is returned */ - virtual Result mediaLoadStart(const std::string& entityId, - const std::optional& agePolicy) const = 0; + [[nodiscard]] virtual Result mediaLoadStart(const std::string& entityId, + const std::optional& agePolicy) const = 0; /** * @brief Called when media playback actually starts due to autoplay, user-initiated play, unpausing, or recovering @@ -135,8 +137,8 @@ class IMetrics * * @retval An ok Result on success, or an error; no value is returned */ - virtual Result mediaPlaying(const std::string& entityId, - const std::optional& agePolicy) const = 0; + [[nodiscard]] virtual Result mediaPlaying(const std::string& entityId, + const std::optional& agePolicy) const = 0; /** * @brief Called when media playback should start due to autoplay, user-initiated play, or unpausing @@ -147,8 +149,8 @@ class IMetrics * * @retval An ok Result on success, or an error; no value is returned */ - virtual Result mediaPlay(const std::string& entityId, - const std::optional& agePolicy) const = 0; + [[nodiscard]] virtual Result mediaPlay(const std::string& entityId, + const std::optional& agePolicy) const = 0; /** * @brief Called when media playback will pause due to an intentional pause operation @@ -159,8 +161,8 @@ class IMetrics * * @retval An ok Result on success, or an error; no value is returned */ - virtual Result mediaPause(const std::string& entityId, - const std::optional& agePolicy) const = 0; + [[nodiscard]] virtual Result mediaPause(const std::string& entityId, + const std::optional& agePolicy) const = 0; /** * @brief Called when media playback will halt due to a network, buffer, or other unintentional constraint @@ -171,8 +173,8 @@ class IMetrics * * @retval An ok Result on success, or an error; no value is returned */ - virtual Result mediaWaiting(const std::string& entityId, - const std::optional& agePolicy) const = 0; + [[nodiscard]] virtual Result mediaWaiting(const std::string& entityId, + const std::optional& agePolicy) const = 0; /** * @brief Called when a seek is initiated during media playback @@ -185,8 +187,8 @@ class IMetrics * * @retval An ok Result on success, or an error; no value is returned */ - virtual Result mediaSeeking(const std::string& entityId, const double target, - const std::optional& agePolicy) const = 0; + [[nodiscard]] virtual Result mediaSeeking(const std::string& entityId, double target, + const std::optional& agePolicy) const = 0; /** * @brief Called when a seek is completed during media playback @@ -200,8 +202,8 @@ class IMetrics * * @retval An ok Result on success, or an error; no value is returned */ - virtual Result mediaSeeked(const std::string& entityId, const double position, - const std::optional& agePolicy) const = 0; + [[nodiscard]] virtual Result mediaSeeked(const std::string& entityId, double position, + const std::optional& agePolicy) const = 0; /** * @brief Called when the playback rate of media is changed @@ -213,8 +215,8 @@ class IMetrics * * @retval An ok Result on success, or an error; no value is returned */ - virtual Result mediaRateChanged(const std::string& entityId, const double rate, - const std::optional& agePolicy) const = 0; + [[nodiscard]] virtual Result mediaRateChanged(const std::string& entityId, double rate, + const std::optional& agePolicy) const = 0; /** * @brief Called when the rendition of media is changed, such as bitrate, dimensions, or profile @@ -229,9 +231,10 @@ class IMetrics * * @retval An ok Result on success, or an error; no value is returned */ - virtual Result mediaRenditionChanged(const std::string& entityId, const unsigned bitrate, const unsigned width, - const unsigned height, const std::optional& profile, - const std::optional& agePolicy) const = 0; + [[nodiscard]] virtual Result + mediaRenditionChanged(const std::string& entityId, unsigned bitrate, unsigned width, unsigned height, + const std::optional& profile, + const std::optional& agePolicy) const = 0; /** * @brief Called when playback has stopped because the end of the media was reached @@ -242,8 +245,8 @@ class IMetrics * * @retval An ok Result on success, or an error; no value is returned */ - virtual Result mediaEnded(const std::string& entityId, - const std::optional& agePolicy) const = 0; + [[nodiscard]] virtual Result mediaEnded(const std::string& entityId, + const std::optional& agePolicy) const = 0; /** * @brief Called to inform the platform of 1st party distributor metrics @@ -255,8 +258,8 @@ class IMetrics * * @retval An ok Result on success, or an error; no value is returned */ - virtual Result event(const std::string& schema, const std::string& data, - const std::optional& agePolicy) const = 0; + [[nodiscard]] virtual Result event(const std::string& schema, const std::string& data, + const std::optional& agePolicy) const = 0; /** * @brief Inform the platform about an app's build info @@ -265,7 +268,7 @@ class IMetrics * * @retval An ok Result on success, or an error; no value is returned */ - virtual Result appInfo(const std::string& build) const = 0; + [[nodiscard]] virtual Result appInfo(const std::string& build) const = 0; }; } // namespace Firebolt::Metrics diff --git a/include/firebolt/network.h b/include/firebolt/network.h index 0a0bc33..8732cf7 100644 --- a/include/firebolt/network.h +++ b/include/firebolt/network.h @@ -34,7 +34,7 @@ class INetwork * * @retval The connection state or error */ - virtual Result connected() const = 0; + [[nodiscard]] virtual Result connected() const = 0; /** * @brief Subscribe to connection changes diff --git a/include/firebolt/presentation.h b/include/firebolt/presentation.h index e7bcf85..687e3a4 100644 --- a/include/firebolt/presentation.h +++ b/include/firebolt/presentation.h @@ -33,7 +33,7 @@ class IPresentation * * @retval The focus state or error */ - virtual Result focused() const = 0; + [[nodiscard]] virtual Result focused() const = 0; /** * @brief Subscribe to focus change notifications diff --git a/include/firebolt/speechsynthesis.h b/include/firebolt/speechsynthesis.h new file mode 100644 index 0000000..a809351 --- /dev/null +++ b/include/firebolt/speechsynthesis.h @@ -0,0 +1,75 @@ +/** + * Copyright 2026 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#pragma once +#include + +#include +#include +#include +namespace Firebolt::SpeechSynthesis +{ +struct Voice +{ + std::string name; + std::string lang; + bool _default; +}; + +enum class UtteranceEventEnum +{ + synthesisStarting, + playbackStarting, + paused, + resumed, + completed, + interrupted, + networkFailed, + synthesisFailed, + playbackFailed +}; + +using UtteranceId = std::uint64_t; + +struct UtteranceEvent +{ + UtteranceId id; + UtteranceEventEnum event; +}; + +class ISpeechSynthesis +{ +public: + virtual ~ISpeechSynthesis() = default; + + [[nodiscard]] virtual Result + speak(const std::string& text, std::optional callSign = std::nullopt, + std::optional language = std::nullopt, std::optional voice = std::nullopt, + std::optional volume = std::nullopt, std::optional rate = std::nullopt, + std::optional pitch = std::nullopt) const = 0; + [[nodiscard]] virtual Result> voices() const = 0; + [[nodiscard]] virtual Result + subscribeOnVoicesChanged(std::function&)>&& notification) = 0; + + [[nodiscard]] virtual Result cancel(UtteranceId id) const = 0; + [[nodiscard]] virtual Result pause(UtteranceId id) const = 0; + [[nodiscard]] virtual Result resume(UtteranceId id) const = 0; + [[nodiscard]] virtual Result + subscribeOnUtteranceEvent(std::function&& notification) = 0; +}; +} // namespace Firebolt::SpeechSynthesis \ No newline at end of file diff --git a/include/firebolt/stats.h b/include/firebolt/stats.h index 17efcd4..8e5dd03 100644 --- a/include/firebolt/stats.h +++ b/include/firebolt/stats.h @@ -24,10 +24,10 @@ namespace Firebolt::Stats { struct MemoryInfo { - uint32_t userMemoryUsed; - uint32_t userMemoryLimit; - uint32_t gpuMemoryUsed; - uint32_t gpuMemoryLimit; + uint64_t userMemoryUsed; + uint64_t userMemoryLimit; + uint64_t gpuMemoryUsed; + uint64_t gpuMemoryLimit; }; class IStats @@ -36,11 +36,11 @@ class IStats virtual ~IStats() = default; /** - @brief Returns information about container memory usage, in units of 1024 bytes - * - * @retval MemoryInfo struct or error - */ - virtual Result memoryUsage() const = 0; + * @brief Returns information about container memory usage in bytes. + * + * @retval MemoryInfo struct or error + */ + [[nodiscard]] virtual Result memoryUsage() const = 0; }; } // namespace Firebolt::Stats diff --git a/include/firebolt/texttospeech.h b/include/firebolt/texttospeech.h index 6650498..ddb8b57 100644 --- a/include/firebolt/texttospeech.h +++ b/include/firebolt/texttospeech.h @@ -91,17 +91,26 @@ class ITextToSpeech * * @retval The list of voices supported for the language */ - virtual Result listVoices(const std::string& language) const = 0; + [[nodiscard]] virtual Result listVoices(const std::string& language) const = 0; /** * @brief Speak the uttered text using the TTS engine * * @param[in] text : String to be converted to Audio for speech + * @param[in] callSign : Optional call sign for the app making the request + * @param[in] language : Optional language for the speech request + * @param[in] voice : Optional voice for the speech request + * @param[in] volume : Optional volume for the speech request + * @param[in] rate : Optional rate for the speech request + * @param[in] pitch : Optional pitch for the speech request * * @retval Result for Speak */ - virtual Result speak(const std::string& text) const = 0; - + [[nodiscard]] virtual Result + speak(const std::string& text, std::optional callSign = std::nullopt, + std::optional language = std::nullopt, std::optional voice = std::nullopt, + std::optional volume = std::nullopt, std::optional rate = std::nullopt, + std::optional pitch = std::nullopt) const = 0; /** * @brief Pauses the speech for given speech id * @@ -109,7 +118,7 @@ class ITextToSpeech * * @retval Result for Pause */ - virtual Result pause(SpeechId speechId) const = 0; + [[nodiscard]] virtual Result pause(SpeechId speechId) const = 0; /** * @brief Resumes the speech for given speech id @@ -118,7 +127,7 @@ class ITextToSpeech * * @retval Result for Resume */ - virtual Result resume(SpeechId speechId) const = 0; + [[nodiscard]] virtual Result resume(SpeechId speechId) const = 0; /** * @brief Cancels the speech for given speech id @@ -127,7 +136,7 @@ class ITextToSpeech * * @retval Result for cancel */ - virtual Result cancel(SpeechId speechId) const = 0; + [[nodiscard]] virtual Result cancel(SpeechId speechId) const = 0; /** * @brief Returns the current state of the speech request. @@ -136,7 +145,7 @@ class ITextToSpeech * * @retval Result for speech state */ - virtual Result getSpeechState(SpeechId speechId) const = 0; + [[nodiscard]] virtual Result getSpeechState(SpeechId speechId) const = 0; /** * @brief Triggered when the text to speech conversion is about to start. It diff --git a/include/firebolt/videooutput.h b/include/firebolt/videooutput.h new file mode 100644 index 0000000..4833e74 --- /dev/null +++ b/include/firebolt/videooutput.h @@ -0,0 +1,188 @@ +/** + * Copyright 2026 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +// +// ============================================================================ +// AUTO-GENERATED by firebolt-sdk-gen v0.1.0 — DO NOT EDIT +// ============================================================================ +#ifndef FIREBOLT_VIDEOOUTPUT_H +#define FIREBOLT_VIDEOOUTPUT_H + +#include +#include +#include +#include +#include + +namespace Firebolt::VideoOutput +{ +enum class CecStateValue +{ + Active, + Inactive, + Unsupported, +}; + +enum class ColorDepthValue +{ + D0, + D10, + D12, + D8, +}; + +enum class ColorFormatValue +{ + None, + Rgb444, + Ycbcr420, + Ycbcr422, + Ycbcr444, +}; + +enum class DynamicRangeValue +{ + DolbyVision, + Hdr10, + Hdr10plus, + Hlg, + None, + Sdr, +}; + +enum class HdcpState +{ + Direct, + Hdcp14, + Hdcp22, + None, +}; + +enum class OutputColorimetry +{ + Bt2020rgb, + Bt2020ycc, + Bt709, + None, + Oprgb, +}; + +enum class QuantizationRangeValue +{ + Full, + Limited, + None, +}; + +enum class RefreshRateValue +{ + R0, + R23976, + R24, + R25, + R2997, + R30, + R50, + R5994, + R60, +}; + +struct VideoOutputResolution +{ + uint32_t height; + uint32_t width; +}; + +class IVideoOutput +{ +public: + virtual ~IVideoOutput() = default; + + [[nodiscard]] virtual Result cecState() const = 0; + virtual Result subscribeOnCecStateChanged(std::function&& notification) = 0; + + [[nodiscard]] virtual Result colorDepth() const = 0; + + [[nodiscard]] virtual Result colorFormat() const = 0; + + [[nodiscard]] virtual Result colorimetry() const = 0; + + [[nodiscard]] virtual Result dynamicRange() const = 0; + + [[nodiscard]] virtual Result hdcp() const = 0; + virtual Result subscribeOnHdcpChanged(std::function&& notification) = 0; + + [[nodiscard]] virtual Result quantizationRange() const = 0; + + [[nodiscard]] virtual Result refreshRate() const = 0; + virtual Result + subscribeOnRefreshRateChanged(std::function&& notification) = 0; + + [[nodiscard]] virtual Result resolution() const = 0; + virtual Result + subscribeOnResolutionChanged(std::function&& notification) = 0; + + virtual Result unsubscribe(SubscriptionId id) = 0; + virtual void unsubscribeAll() = 0; + +}; // class IVideoOutput + +#ifndef FIREBOLT_NO_METHOD_AVAILABILITY + +/// Enum of available methods in this module for runtime capability queries. +enum class MethodId +{ + resolution = 0, + hdcp = 1, + cecState = 2, + refreshRate = 3, + colorDepth = 4, + colorFormat = 5, + colorimetry = 6, + dynamicRange = 7, + quantizationRange = 8, +}; + +/// Check if a method is available in this SDK build. +/// Returns true if the method is compiled in, false otherwise. +/// All methods in this build are currently available (true). +inline bool isMethodAvailable(MethodId method) noexcept +{ + static constexpr bool kMethodAvailable[] = { + true, true, true, true, true, true, true, true, true, + }; + const int idx = static_cast(method); + if (idx < 0 || idx >= static_cast(sizeof(kMethodAvailable) / sizeof(kMethodAvailable[0]))) + { + return false; + } + return kMethodAvailable[idx]; +} + +/// Static array containing all available methods in this module. +/// Use for convenient enumeration: for (auto m : allMethods) { ... } +static constexpr std::array allMethods = { + MethodId::resolution, MethodId::hdcp, MethodId::cecState, + MethodId::refreshRate, MethodId::colorDepth, MethodId::colorFormat, + MethodId::colorimetry, MethodId::dynamicRange, MethodId::quantizationRange, +}; + +#endif // FIREBOLT_NO_METHOD_AVAILABILITY + +} // namespace Firebolt::VideoOutput + +#endif // FIREBOLT_VIDEOOUTPUT_H diff --git a/lint.sh b/lint.sh index 552384d..17a1ced 100755 --- a/lint.sh +++ b/lint.sh @@ -19,111 +19,61 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -BUILD_DIR="build-dev" -NO_BUILD=false -CLEAN=false -RUN_CLANG_TIDY=true -RUN_CPPCHECK=true -RUN_CLANG_FORMAT=true -APPLY_FIXES=false -FORMAT_FIX=false -CLANG_TIDY_PATHS=(src include test/unit test/component) -CLANG_FORMAT_PATHS=(src include test) +MODE="check" +USE_DOCKER="auto" +IMAGE="${LINT_DOCKER_IMAGE:-firebolt-cpp-client-fmt:local}" usage() { cat < Build directory containing compile_commands.json (default: build-dev) - --tidy-path

Add path for clang-tidy scan (repeatable) - --format-path

Add path for clang-format scan (repeatable) - --fix Apply clang-tidy fix-its (clang-tidy only) - --format-fix Apply clang-format fixes in-place - --format-only Run clang-format only - --no-format Skip clang-format checks - --tidy-only Run clang-tidy only - --cppcheck-only Run cppcheck only - --help Show this help + --fix Reformat files in place (same file set as CI) + --check Explicitly run check mode (default) + --docker Force Docker execution + --local Force local clang-format execution + --image Docker image name (default: firebolt-cpp-client-fmt:local) + --help Show this help + +Environment: + SKIP_DOCKER=1 Same as --local Examples: ./lint.sh - ./lint.sh --tidy-only - ./lint.sh --tidy-only --fix - ./lint.sh --format-only - ./lint.sh --format-fix - ./lint.sh --tidy-path test/api_test_app - ./lint.sh --format-path include/firebolt - ./lint.sh --no-build --build-dir build-dev + ./lint.sh --fix + ./lint.sh --docker + SKIP_DOCKER=1 ./lint.sh EOF } while [[ $# -gt 0 ]]; do case "$1" in - --clean) - CLEAN=true + --fix) + MODE="fix" ;; - --no-build) - NO_BUILD=true + --check) + MODE="check" ;; - --build-dir) - if [[ $# -lt 2 || -z "${2:-}" || "$2" == --* ]]; then - echo "Missing value for --build-dir" >&2 - usage - exit 1 - fi - BUILD_DIR="${2:-}" - shift + --docker) + USE_DOCKER="true" ;; - --tidy-path) - if [[ $# -lt 2 || -z "${2:-}" || "$2" == --* ]]; then - echo "Missing value for --tidy-path" >&2 - usage - exit 1 - fi - CLANG_TIDY_PATHS+=("${2:-}") - shift + --local) + USE_DOCKER="false" ;; - --format-path) + --image) if [[ $# -lt 2 || -z "${2:-}" || "$2" == --* ]]; then - echo "Missing value for --format-path" >&2 + echo "Missing value for --image" >&2 usage exit 1 fi - CLANG_FORMAT_PATHS+=("${2:-}") + IMAGE="$2" shift ;; - --fix) - APPLY_FIXES=true - ;; - --format-fix) - FORMAT_FIX=true - RUN_CLANG_FORMAT=true - ;; - --format-only) - RUN_CLANG_FORMAT=true - RUN_CLANG_TIDY=false - RUN_CPPCHECK=false - ;; - --no-format) - RUN_CLANG_FORMAT=false - ;; - --tidy-only) - RUN_CLANG_FORMAT=false - RUN_CLANG_TIDY=true - RUN_CPPCHECK=false - ;; - --cppcheck-only) - RUN_CLANG_FORMAT=false - RUN_CLANG_TIDY=false - RUN_CPPCHECK=true - ;; --help|-h) usage exit 0 @@ -137,167 +87,55 @@ while [[ $# -gt 0 ]]; do shift done -cd "$ROOT_DIR" - -if [[ "$RUN_CLANG_TIDY" == true && "$NO_BUILD" == false && "$BUILD_DIR" != "build-dev" ]]; then - echo "--build-dir is only supported with --no-build (build step always uses build-dev)." >&2 - exit 1 -fi - -if [[ "$RUN_CLANG_FORMAT" == false && "$RUN_CLANG_TIDY" == false && "$RUN_CPPCHECK" == false ]]; then - echo "Nothing to run: clang-format, clang-tidy, and cppcheck are all disabled." >&2 - exit 1 -fi - -if [[ "$APPLY_FIXES" == true && "$RUN_CLANG_TIDY" == false ]]; then - echo "--fix requires clang-tidy to be enabled (remove --cppcheck-only)." >&2 - exit 1 -fi - -if [[ "$FORMAT_FIX" == true && "$RUN_CLANG_FORMAT" == false ]]; then - echo "--format-fix requires clang-format to be enabled (remove --no-format)." >&2 - exit 1 -fi - -if [[ "$RUN_CLANG_TIDY" == true ]] && ! command -v clang-tidy >/dev/null 2>&1; then - echo "clang-tidy not found. Install it (e.g. apt install clang-tidy)." >&2 - exit 1 -fi - -if [[ "$RUN_CPPCHECK" == true ]] && ! command -v cppcheck >/dev/null 2>&1; then - echo "cppcheck not found. Install it (e.g. apt install cppcheck)." >&2 - exit 1 -fi - -if [[ "$RUN_CLANG_FORMAT" == true ]] && ! command -v clang-format >/dev/null 2>&1; then - echo "clang-format not found. Install it (e.g. apt install clang-format)." >&2 - exit 1 -fi - -if [[ "$CLEAN" == true ]]; then - rm -rf "$BUILD_DIR" -fi - -if [[ "$NO_BUILD" == false && "$RUN_CLANG_TIDY" == true ]]; then - ./build.sh +tests -fi - -if [[ "$RUN_CLANG_TIDY" == true && ! -f "$BUILD_DIR/compile_commands.json" ]]; then - echo "Missing $BUILD_DIR/compile_commands.json. Run ./build.sh +tests first." >&2 - exit 1 -fi - -if [[ "$RUN_CLANG_FORMAT" == true ]]; then - if [[ "$FORMAT_FIX" == true ]]; then - echo "[lint] Running clang-format with fixes enabled" +if [[ "${SKIP_DOCKER:-0}" == "1" ]]; then + USE_DOCKER="false" +elif [[ "$USE_DOCKER" == "auto" ]]; then + if command -v docker >/dev/null 2>&1; then + USE_DOCKER="true" else - echo "[lint] Running clang-format check" - fi - - format_paths=() - for p in "${CLANG_FORMAT_PATHS[@]}"; do - if [[ -e "$p" ]]; then - format_paths+=("$p") - fi - done - - if [[ ${#format_paths[@]} -eq 0 ]]; then - echo "No valid clang-format paths found." >&2 - exit 1 - fi - - mapfile -t format_files < <( - find "${format_paths[@]}" -type f \( -name "*.h" -o -name "*.hh" -o -name "*.hpp" -o -name "*.hxx" -o -name "*.c" -o -name "*.cc" -o -name "*.cpp" -o -name "*.cxx" \) | sort - ) - - if [[ ${#format_files[@]} -eq 0 ]]; then - echo "No C/C++ files found for clang-format." >&2 - exit 1 + USE_DOCKER="false" fi +fi - clang_format_failed=0 - total_format_files=${#format_files[@]} - format_index=0 - for f in "${format_files[@]}"; do - format_index=$((format_index + 1)) - echo "[lint][clang-format] ${format_index}/${total_format_files}: $f" - if [[ "$FORMAT_FIX" == true ]]; then - clang-format -i "$f" - else - if ! clang-format --dry-run --Werror "$f"; then - clang_format_failed=1 - fi - fi - done +cd "$ROOT_DIR" - if [[ "$FORMAT_FIX" == false && $clang_format_failed -ne 0 ]]; then - echo "clang-format reported issues." >&2 - echo "Run ./lint.sh --format-fix to apply formatting automatically." >&2 - exit 1 +if [[ "$USE_DOCKER" == "true" ]]; then + if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then + echo "[lint] Building Docker image '$IMAGE' with clang-format (one-time)" + docker build -t "$IMAGE" - <<'DOCKERFILE' +FROM ubuntu:24.04 +RUN apt-get update \ + && apt-get install -y --no-install-recommends clang-format git \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /workspace +DOCKERFILE fi -fi -if [[ "$RUN_CLANG_TIDY" == true ]]; then - if [[ "$APPLY_FIXES" == true ]]; then - echo "[lint] Running clang-tidy with fixes enabled" + if [[ "$MODE" == "fix" ]]; then + echo "[lint] Running CI-equivalent clang-format file set in FIX mode via Docker" + docker run --rm --user "$(id -u):$(id -g)" -v "$ROOT_DIR:/workspace" "$IMAGE" \ + bash -lc "set -e && git ls-files -- '*.cpp' '*.h' | xargs clang-format -i" + echo "[lint] Formatting fixes applied" else - echo "[lint] Running clang-tidy" - fi - - existing_paths=() - for p in "${CLANG_TIDY_PATHS[@]}"; do - if [[ -e "$p" ]]; then - existing_paths+=("$p") - fi - done - - if [[ ${#existing_paths[@]} -eq 0 ]]; then - echo "No valid clang-tidy paths found." >&2 - exit 1 + echo "[lint] Running CI-equivalent clang-format check via Docker" + docker run --rm --user "$(id -u):$(id -g)" -v "$ROOT_DIR:/workspace" "$IMAGE" \ + bash -lc "set -e && git ls-files -- '*.cpp' '*.h' | xargs clang-format --dry-run --Werror" + echo "[lint] Formatting OK" fi - - mapfile -t source_files < <( - find "${existing_paths[@]}" -type f \( -name "*.c" -o -name "*.cc" -o -name "*.cpp" -o -name "*.cxx" \) | sort - ) - - if [[ ${#source_files[@]} -eq 0 ]]; then - echo "No C/C++ source files found for clang-tidy." >&2 +else + if ! command -v clang-format >/dev/null 2>&1; then + echo "clang-format not found. Install it or run without SKIP_DOCKER=1." >&2 exit 1 fi - clang_tidy_failed=0 - total_files=${#source_files[@]} - index=0 - for f in "${source_files[@]}"; do - index=$((index + 1)) - echo "[lint][clang-tidy] ${index}/${total_files}: $f" - clang_tidy_cmd=(clang-tidy -p "$BUILD_DIR") - if [[ "$APPLY_FIXES" == true ]]; then - clang_tidy_cmd+=("-fix") - fi - clang_tidy_cmd+=("$f") - if ! "${clang_tidy_cmd[@]}"; then - clang_tidy_failed=1 - fi - done - - if [[ $clang_tidy_failed -ne 0 ]]; then - echo "clang-tidy reported issues." >&2 - exit 1 + echo "[lint] Using local clang-format: $(clang-format --version)" + if [[ "$MODE" == "fix" ]]; then + echo "[lint] Running CI-equivalent clang-format file set in FIX mode" + git ls-files -- '*.cpp' '*.h' | xargs clang-format -i + echo "[lint] Formatting fixes applied" + else + echo "[lint] Running CI-equivalent clang-format check" + git ls-files -- '*.cpp' '*.h' | xargs clang-format --dry-run --Werror + echo "[lint] Formatting OK" fi fi - -if [[ "$RUN_CPPCHECK" == true ]]; then - echo "[lint] Running cppcheck" - cppcheck \ - --enable=warning,style,performance,portability \ - --std=c++17 \ - --language=c++ \ - --inline-suppr \ - --error-exitcode=1 \ - -I include \ - -I src \ - src include test -fi - -echo "[lint] Completed successfully" diff --git a/src/accessibility_impl.h b/src/accessibility_impl.h index 6a327fb..3ff383c 100644 --- a/src/accessibility_impl.h +++ b/src/accessibility_impl.h @@ -33,22 +33,22 @@ class AccessibilityImpl : public IAccessibility ~AccessibilityImpl() override = default; - Result audioDescription() const override; + [[nodiscard]] Result audioDescription() const override; Result subscribeOnAudioDescriptionChanged(std::function&& notification) override; - Result closedCaptionsSettings() const override; + [[nodiscard]] Result closedCaptionsSettings() const override; Result subscribeOnClosedCaptionsSettingsChanged(std::function&& notification) override; - Result highContrastUI() const override; + [[nodiscard]] Result highContrastUI() const override; Result subscribeOnHighContrastUIChanged(std::function&& notification) override; - Result voiceGuidanceSettings() const override; + [[nodiscard]] Result voiceGuidanceSettings() const override; Result subscribeOnVoiceGuidanceSettingsChanged(std::function&& notification) override; - virtual Result unsubscribe(SubscriptionId id) override; - virtual void unsubscribeAll() override; + Result unsubscribe(SubscriptionId id) override; + void unsubscribeAll() override; private: Firebolt::Helpers::IHelper& helper_; diff --git a/src/actions_impl.cpp b/src/actions_impl.cpp index b699bca..887fb55 100644 --- a/src/actions_impl.cpp +++ b/src/actions_impl.cpp @@ -32,14 +32,29 @@ ActionsImpl::ActionsImpl(Firebolt::Helpers::IHelper& helper) { } -Result ActionsImpl::intent() const +Result ActionsImpl::intent() const { - return helper_.get("Actions.intent"); + return helper_.get("Actions.intent"); } -Result ActionsImpl::subscribeOnIntent(std::function&& notification) +Result ActionsImpl::subscribeOnIntent(std::function&& notification) { - return subscriptionManager_.subscribe("Actions.onIntent", std::move(notification)); + return subscriptionManager_.subscribe("Actions.onIntent", std::move(notification)); +} + +Result ActionsImpl::start(const IntentData& intent, std::optional handlerAppId) const +{ + nlohmann::json params; + params["intent"]["action"] = intent.action; + if (intent.context && intent.context->source) + { + params["intent"]["context"]["source"] = *intent.context->source; + } + if (handlerAppId) + { + params["handlerAppId"] = *handlerAppId; + } + return helper_.invoke("Actions.start", params); } Result ActionsImpl::unsubscribe(SubscriptionId id) diff --git a/src/actions_impl.h b/src/actions_impl.h index 4c8d6ba..a61d03b 100644 --- a/src/actions_impl.h +++ b/src/actions_impl.h @@ -36,9 +36,12 @@ class ActionsImpl : public IActions ActionsImpl& operator=(const ActionsImpl&) = delete; ~ActionsImpl() override = default; - Result intent() const override; + [[nodiscard]] Result intent() const override; - Result subscribeOnIntent(std::function&& notification) override; + Result subscribeOnIntent(std::function&& notification) override; + + [[nodiscard]] Result start(const IntentData& intent, + std::optional handlerAppId = std::nullopt) const override; Result unsubscribe(SubscriptionId id) override; void unsubscribeAll() override; diff --git a/src/advertising_impl.h b/src/advertising_impl.h index efad60f..cd0a5ff 100644 --- a/src/advertising_impl.h +++ b/src/advertising_impl.h @@ -32,7 +32,7 @@ class AdvertisingImpl : public IAdvertising ~AdvertisingImpl() override = default; - Result advertisingId() const override; + [[nodiscard]] Result advertisingId() const override; private: Firebolt::Helpers::IHelper& helper_; diff --git a/src/device_impl.cpp b/src/device_impl.cpp index 58447ff..9d899c7 100644 --- a/src/device_impl.cpp +++ b/src/device_impl.cpp @@ -37,6 +37,31 @@ Result DeviceImpl::deviceClass() const return Result(helper_.get("Device.deviceClass")); } +Result DeviceImpl::osName() const +{ + return helper_.get("Device.osName"); +} + +Result DeviceImpl::setOsName(const std::string& osName) +{ + return helper_.invoke("Device.setOsName", nlohmann::json(osName)); +} + +Result DeviceImpl::osVersion() const +{ + return helper_.get("Device.osVersion"); +} + +Result DeviceImpl::setOsVersion(const std::string& osVersion) +{ + return helper_.invoke("Device.setOsVersion", nlohmann::json(osVersion)); +} + +Result DeviceImpl::firmware() const +{ + return helper_.get("Device.firmware"); +} + Result DeviceImpl::hdr() const { return Result(helper_.get("Device.hdr")); @@ -71,4 +96,15 @@ void DeviceImpl::unsubscribeAll() { subscriptionManager_.unsubscribeAll(); } + +Result DeviceImpl::dolbyAtmosExperienceAvailable() const +{ + return helper_.get("Device.dolbyAtmosExperienceAvailable"); +} + +Result DeviceImpl::subscribeOnDolbyAtmosExperienceAvailableChanged(std::function&& notification) +{ + return subscriptionManager_.subscribe("Device.onDolbyAtmosExperienceAvailableChanged", + std::move(notification)); +} } // namespace Firebolt::Device diff --git a/src/device_impl.h b/src/device_impl.h index ca9a29f..297eede 100644 --- a/src/device_impl.h +++ b/src/device_impl.h @@ -32,18 +32,27 @@ class DeviceImpl : public IDevice ~DeviceImpl() override = default; - Result chipsetId() const override; - Result deviceClass() const override; - Result hdr() const override; - Result timeInActiveState() const override; - Result uid() const override; - Result uptime() const override; + [[nodiscard]] Result chipsetId() const override; + [[nodiscard]] Result deviceClass() const override; + [[nodiscard]] Result osName() const override; + [[nodiscard]] Result setOsName(const std::string& osName) override; + [[nodiscard]] Result osVersion() const override; + [[nodiscard]] Result setOsVersion(const std::string& osVersion) override; + [[nodiscard]] Result firmware() const override; + [[nodiscard]] Result hdr() const override; + [[nodiscard]] Result timeInActiveState() const override; + [[nodiscard]] Result uid() const override; + [[nodiscard]] Result uptime() const override; Result subscribeOnHdrChanged(std::function&& notification) override; Result unsubscribe(SubscriptionId id) override; void unsubscribeAll() override; + [[nodiscard]] Result dolbyAtmosExperienceAvailable() const override; + Result + subscribeOnDolbyAtmosExperienceAvailableChanged(std::function&& notification) override; + private: Firebolt::Helpers::IHelper& helper_; Firebolt::Helpers::SubscriptionManager subscriptionManager_; diff --git a/src/discovery_impl.cpp b/src/discovery_impl.cpp index 67b0f5b..242702d 100644 --- a/src/discovery_impl.cpp +++ b/src/discovery_impl.cpp @@ -53,7 +53,7 @@ Result DiscoveryImpl::watched(const std::string& entityId, std::optional("Discovery.watched", parameters); } -Result DiscoveryImpl::watchedV2(const std::string& entityId, std::optional progress, +Result DiscoveryImpl::watchedV2(const std::string& entityId, std::optional progress, std::optional completed, std::optional watchedOn, std::optional agePolicy) const { @@ -76,6 +76,6 @@ Result DiscoveryImpl::watchedV2(const std::string& entityId, std::optional parameters["agePolicy"] = Firebolt::JSON::toString(Firebolt::JsonData::AgePolicyEnum, *agePolicy); } - return helper_.get("Discovery.watchedV2", parameters); + return helper_.invoke("Discovery.watched", parameters); } } // namespace Firebolt::Discovery diff --git a/src/discovery_impl.h b/src/discovery_impl.h index 7710e40..6f864ec 100644 --- a/src/discovery_impl.h +++ b/src/discovery_impl.h @@ -33,13 +33,13 @@ class DiscoveryImpl : public IDiscovery ~DiscoveryImpl() override = default; - Result watched(const std::string& entityId, std::optional progress, std::optional completed, - std::optional watchedOn, - std::optional agePolicy) const override; + [[nodiscard]] Result watched(const std::string& entityId, std::optional progress, + std::optional completed, std::optional watchedOn, + std::optional agePolicy) const override; - Result watchedV2(const std::string& entityId, std::optional progress, std::optional completed, - std::optional watchedOn, - std::optional agePolicy) const override; + [[nodiscard]] Result watchedV2(const std::string& entityId, std::optional progress, + std::optional completed, std::optional watchedOn, + std::optional agePolicy) const override; private: Firebolt::Helpers::IHelper& helper_; diff --git a/src/display_impl.h b/src/display_impl.h index bbe5bd7..0b99e72 100644 --- a/src/display_impl.h +++ b/src/display_impl.h @@ -32,9 +32,9 @@ class DisplayImpl : public IDisplay ~DisplayImpl() override = default; - Result edid() const override; - Result maxResolution() const override; - Result size() const override; + [[nodiscard]] Result edid() const override; + [[nodiscard]] Result maxResolution() const override; + [[nodiscard]] Result size() const override; private: Firebolt::Helpers::IHelper& helper_; diff --git a/src/firebolt.cpp b/src/firebolt.cpp index 0afad53..8748de6 100644 --- a/src/firebolt.cpp +++ b/src/firebolt.cpp @@ -29,8 +29,10 @@ #include "metrics_impl.h" #include "network_impl.h" #include "presentation_impl.h" +#include "speechsynthesis_impl.h" #include "stats_impl.h" #include "texttospeech_impl.h" +#include "videooutput_impl.h" #include namespace Firebolt @@ -50,15 +52,17 @@ class FireboltAccessorImpl : public IFireboltAccessor metrics_(Firebolt::Helpers::GetHelperInstance()), network_(Firebolt::Helpers::GetHelperInstance()), presentation_(Firebolt::Helpers::GetHelperInstance()), + speechSynthesis_(Firebolt::Helpers::GetHelperInstance()), stats_(Firebolt::Helpers::GetHelperInstance()), - textToSpeech_(Firebolt::Helpers::GetHelperInstance()) + textToSpeech_(Firebolt::Helpers::GetHelperInstance()), + videooutput_(Firebolt::Helpers::GetHelperInstance()) { } FireboltAccessorImpl(const FireboltAccessorImpl&) = delete; FireboltAccessorImpl& operator=(const FireboltAccessorImpl&) = delete; - ~FireboltAccessorImpl() { unsubscribeAll(); } + ~FireboltAccessorImpl() override { unsubscribeAll(); } Firebolt::Error Connect(const Firebolt::Config& config, OnConnectionChanged listener) override { @@ -83,9 +87,11 @@ class FireboltAccessorImpl : public IFireboltAccessor Metrics::IMetrics& MetricsInterface() override { return metrics_; } Network::INetwork& NetworkInterface() override { return network_; } Presentation::IPresentation& PresentationInterface() override { return presentation_; } + SpeechSynthesis::ISpeechSynthesis& SpeechSynthesisInterface() override { return speechSynthesis_; } Stats::IStats& StatsInterface() override { return stats_; } TextToSpeech::ITextToSpeech& TextToSpeechInterface() override { return textToSpeech_; } Actions::IActions& ActionsInterface() override { return actions_; } + VideoOutput::IVideoOutput& VideoOutputInterface() override { return videooutput_; } private: void unsubscribeAll() @@ -97,9 +103,9 @@ class FireboltAccessorImpl : public IFireboltAccessor network_.unsubscribeAll(); presentation_.unsubscribeAll(); textToSpeech_.unsubscribeAll(); + videooutput_.unsubscribeAll(); } -private: Accessibility::AccessibilityImpl accessibility_; Advertising::AdvertisingImpl advertising_; Actions::ActionsImpl actions_; @@ -111,8 +117,10 @@ class FireboltAccessorImpl : public IFireboltAccessor Metrics::MetricsImpl metrics_; Network::NetworkImpl network_; Presentation::PresentationImpl presentation_; + SpeechSynthesis::SpeechSynthesisImpl speechSynthesis_; Stats::StatsImpl stats_; TextToSpeech::TextToSpeechImpl textToSpeech_; + VideoOutput::VideoOutputImpl videooutput_; }; /* static */ IFireboltAccessor& IFireboltAccessor::Instance() diff --git a/src/json_types/accessibility.h b/src/json_types/accessibility.h index e071f04..4037b10 100644 --- a/src/json_types/accessibility.h +++ b/src/json_types/accessibility.h @@ -36,7 +36,7 @@ class ClosedCaptionsSettings : public Firebolt::JSON::NL_Json_Basic<::Firebolt:: enabled_ = json["enabled"].get(); preferredLanguages_ = json["preferredLanguages"].get>(); } - ::Firebolt::Accessibility::ClosedCaptionsSettings value() const override + [[nodiscard]] ::Firebolt::Accessibility::ClosedCaptionsSettings value() const override { return ::Firebolt::Accessibility::ClosedCaptionsSettings{enabled_, preferredLanguages_}; } @@ -59,7 +59,7 @@ class VoiceGuidanceSettings : public Firebolt::JSON::NL_Json_Basic<::Firebolt::A rate_ = json["rate"].get(); navigationHints_ = json["navigationHints"].get(); } - ::Firebolt::Accessibility::VoiceGuidanceSettings value() const override + [[nodiscard]] ::Firebolt::Accessibility::VoiceGuidanceSettings value() const override { return ::Firebolt::Accessibility::VoiceGuidanceSettings{enabled_, rate_, navigationHints_}; } diff --git a/src/json_types/actions.h b/src/json_types/actions.h index 60c76ce..0310384 100644 --- a/src/json_types/actions.h +++ b/src/json_types/actions.h @@ -26,16 +26,42 @@ #include "firebolt/actions.h" #include #include -#include +#include -namespace Firebolt::Actions +namespace Firebolt::Actions::JsonData { -namespace JsonData +// Deserialises the wire object {"intent":{"action":"...","context":{"source":"..."}},"intentId":N} +// into Firebolt::Actions::Intent. nlohmann stays hidden in this impl-layer header. +class JsonValue : public Firebolt::JSON::NL_Json_Basic { +public: + void fromJson(const nlohmann::json& json) override + { + value_ = {}; + if (!checkRequiredFields(json, {"intent", "intentId"}) || !json["intent"].is_object() || + !checkRequiredFields(json["intent"], {"action"})) + { + throw std::invalid_argument("Missing required fields in JSON"); + } + value_.intent.action = json["intent"]["action"].get(); + if (json["intent"].contains("context") && json["intent"]["context"].is_object()) + { + IntentContext ctx; + if (json["intent"]["context"].contains("source")) + { + ctx.source = json["intent"]["context"]["source"].get(); + } + value_.intent.context = ctx; + } + value_.intentId = json["intentId"].get(); + } + [[nodiscard]] Intent value() const override { return value_; } -} // namespace JsonData +private: + Intent value_; +}; -} // namespace Firebolt::Actions +} // namespace Firebolt::Actions::JsonData #endif // FIREBOLT_ACTIONS_JSON_H diff --git a/src/json_types/advertising.h b/src/json_types/advertising.h index 38b2ecc..3036557 100644 --- a/src/json_types/advertising.h +++ b/src/json_types/advertising.h @@ -38,7 +38,10 @@ class IfaJson : public Firebolt::JSON::NL_Json_Basic<::Firebolt::Advertising::If ifa_type = json["ifa_type"].get(); lmt = json["lmt"].get(); } - ::Firebolt::Advertising::Ifa value() const override { return ::Firebolt::Advertising::Ifa{ifa, ifa_type, lmt}; } + [[nodiscard]] ::Firebolt::Advertising::Ifa value() const override + { + return ::Firebolt::Advertising::Ifa{ifa, ifa_type, lmt}; + } private: std::string ifa; diff --git a/src/json_types/device.h b/src/json_types/device.h index 82c6993..9e78aea 100644 --- a/src/json_types/device.h +++ b/src/json_types/device.h @@ -36,7 +36,7 @@ class DeviceClassJson : public Firebolt::JSON::NL_Json_Basic<::Firebolt::Device: { public: void fromJson(const nlohmann::json& json) override { deviceClass_ = DeviceClassEnum.at(json); } - ::Firebolt::Device::DeviceClass value() const override { return deviceClass_; } + [[nodiscard]] ::Firebolt::Device::DeviceClass value() const override { return deviceClass_; } private: ::Firebolt::Device::DeviceClass deviceClass_; @@ -56,7 +56,7 @@ class HDRFormat : public Firebolt::JSON::NL_Json_Basic<::Firebolt::Device::HDRFo hdrFormat_.dolbyVision = json["dolbyVision"].get(); hdrFormat_.hlg = json["hlg"].get(); } - ::Firebolt::Device::HDRFormat value() const override { return hdrFormat_; } + [[nodiscard]] ::Firebolt::Device::HDRFormat value() const override { return hdrFormat_; } private: ::Firebolt::Device::HDRFormat hdrFormat_; diff --git a/src/json_types/display.h b/src/json_types/display.h index 7e14b18..40877c2 100644 --- a/src/json_types/display.h +++ b/src/json_types/display.h @@ -38,7 +38,10 @@ class DisplaySizeJson : public Firebolt::JSON::NL_Json_Basic<::Firebolt::Display width_ = json["width"].get(); height_ = json["height"].get(); } - ::Firebolt::Display::DisplaySize value() const override { return Firebolt::Display::DisplaySize{width_, height_}; } + [[nodiscard]] ::Firebolt::Display::DisplaySize value() const override + { + return Firebolt::Display::DisplaySize{width_, height_}; + } private: uint32_t width_; diff --git a/src/json_types/lifecycle.h b/src/json_types/lifecycle.h index acc43dd..0b130be 100644 --- a/src/json_types/lifecycle.h +++ b/src/json_types/lifecycle.h @@ -44,7 +44,7 @@ class LifecycleState : public Firebolt::JSON::NL_Json_Basic<::Firebolt::Lifecycl { public: void fromJson(const nlohmann::json& json) override { state_ = LifecycleStateEnum.at(json.get()); } - ::Firebolt::Lifecycle::LifecycleState value() const override { return state_; } + [[nodiscard]] ::Firebolt::Lifecycle::LifecycleState value() const override { return state_; } private: ::Firebolt::Lifecycle::LifecycleState state_; @@ -62,7 +62,7 @@ class StateChange : public Firebolt::JSON::NL_Json_Basic<::Firebolt::Lifecycle:: oldState_ = LifecycleStateEnum.at(json["oldState"]); newState_ = LifecycleStateEnum.at(json["newState"]); } - ::Firebolt::Lifecycle::StateChange value() const override + [[nodiscard]] ::Firebolt::Lifecycle::StateChange value() const override { return ::Firebolt::Lifecycle::StateChange{oldState_, newState_}; } diff --git a/src/json_types/speechsynthesis.h b/src/json_types/speechsynthesis.h new file mode 100644 index 0000000..00f4993 --- /dev/null +++ b/src/json_types/speechsynthesis.h @@ -0,0 +1,97 @@ +/** + * Copyright 2026 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#pragma once + +#include "firebolt/speechsynthesis.h" +#include + +namespace Firebolt::SpeechSynthesis::JsonData +{ +inline const Firebolt::JSON::EnumType<::Firebolt::SpeechSynthesis::UtteranceEventEnum> UtteranceEventEnum({ + {"synthesisStarting", ::Firebolt::SpeechSynthesis::UtteranceEventEnum::synthesisStarting}, + {"playbackStarting", ::Firebolt::SpeechSynthesis::UtteranceEventEnum::playbackStarting}, + {"paused", ::Firebolt::SpeechSynthesis::UtteranceEventEnum::paused}, + {"resumed", ::Firebolt::SpeechSynthesis::UtteranceEventEnum::resumed}, + {"completed", ::Firebolt::SpeechSynthesis::UtteranceEventEnum::completed}, + {"interrupted", ::Firebolt::SpeechSynthesis::UtteranceEventEnum::interrupted}, + {"networkFailed", ::Firebolt::SpeechSynthesis::UtteranceEventEnum::networkFailed}, + {"synthesisFailed", ::Firebolt::SpeechSynthesis::UtteranceEventEnum::synthesisFailed}, + {"playbackFailed", ::Firebolt::SpeechSynthesis::UtteranceEventEnum::playbackFailed}, +}); + +class VoicesResponse : public Firebolt::JSON::NL_Json_Basic<::std::pmr::vector<::Firebolt::SpeechSynthesis::Voice>> +{ +public: + void fromJson(const nlohmann::json& json) override + { + if (!json.is_array()) + { + throw std::invalid_argument("Expected an array for voices response"); + } + + voices_.clear(); + for (const auto& item : json) + { + if (!checkRequiredFields(item, {"name", "lang", "default"})) + { + throw std::invalid_argument("Missing required fields in JSON"); + } + + voices_.push_back(::Firebolt::SpeechSynthesis::Voice{item["name"].get(), + item["lang"].get(), + item["default"].get()}); + } + } + + [[nodiscard]] ::std::pmr::vector<::Firebolt::SpeechSynthesis::Voice> value() const override { return voices_; } + +private: + ::std::pmr::vector<::Firebolt::SpeechSynthesis::Voice> voices_; +}; + +class UtteranceEventResponse : public Firebolt::JSON::NL_Json_Basic<::Firebolt::SpeechSynthesis::UtteranceEvent> +{ +public: + void fromJson(const nlohmann::json& json) override + { + if (!checkRequiredFields(json, {"id", "event"})) + { + throw std::invalid_argument("Missing required fields in JSON"); + } + + const auto eventName = json["event"].get(); + const auto eventIt = UtteranceEventEnum.find(eventName); + if (eventIt == UtteranceEventEnum.end()) + { + throw std::invalid_argument("Unknown utterance event"); + } + event_ = eventIt->second; + id_ = json["id"].get<::Firebolt::SpeechSynthesis::UtteranceId>(); + } + + [[nodiscard]] ::Firebolt::SpeechSynthesis::UtteranceEvent value() const override + { + return ::Firebolt::SpeechSynthesis::UtteranceEvent{id_, event_}; + } + +private: + ::Firebolt::SpeechSynthesis::UtteranceId id_; + ::Firebolt::SpeechSynthesis::UtteranceEventEnum event_; +}; +} // namespace Firebolt::SpeechSynthesis::JsonData \ No newline at end of file diff --git a/src/json_types/stats.h b/src/json_types/stats.h index 45576d7..89610a9 100644 --- a/src/json_types/stats.h +++ b/src/json_types/stats.h @@ -29,25 +29,24 @@ class MemoryInfo : public Firebolt::JSON::NL_Json_Basic<::Firebolt::Stats::Memor public: void fromJson(const nlohmann::json& json) override { - if (!checkRequiredFields(json, - {"userMemoryUsedKiB", "userMemoryLimitKiB", "gpuMemoryUsedKiB", "gpuMemoryLimitKiB"})) + if (!checkRequiredFields(json, {"userMemoryUsed", "userMemoryLimit", "gpuMemoryUsed", "gpuMemoryLimit"})) { throw std::invalid_argument("Missing required fields in JSON"); } - userMemoryUsed = json["userMemoryUsedKiB"].get(); - userMemoryLimit = json["userMemoryLimitKiB"].get(); - gpuMemoryUsed = json["gpuMemoryUsedKiB"].get(); - gpuMemoryLimit = json["gpuMemoryLimitKiB"].get(); + userMemoryUsed = json["userMemoryUsed"].get(); + userMemoryLimit = json["userMemoryLimit"].get(); + gpuMemoryUsed = json["gpuMemoryUsed"].get(); + gpuMemoryLimit = json["gpuMemoryLimit"].get(); } - ::Firebolt::Stats::MemoryInfo value() const override + [[nodiscard]] ::Firebolt::Stats::MemoryInfo value() const override { return ::Firebolt::Stats::MemoryInfo{userMemoryUsed, userMemoryLimit, gpuMemoryUsed, gpuMemoryLimit}; } private: - uint32_t userMemoryUsed; - uint32_t userMemoryLimit; - uint32_t gpuMemoryUsed; - uint32_t gpuMemoryLimit; + uint64_t userMemoryUsed; + uint64_t userMemoryLimit; + uint64_t gpuMemoryUsed; + uint64_t gpuMemoryLimit; }; } // namespace Firebolt::Stats::JsonData diff --git a/src/json_types/texttospeech.h b/src/json_types/texttospeech.h index b0f2686..481bb0a 100644 --- a/src/json_types/texttospeech.h +++ b/src/json_types/texttospeech.h @@ -47,7 +47,7 @@ class ListVoicesResponse : public Firebolt::JSON::NL_Json_Basic<::Firebolt::Text voices_.push_back(voice.get()); } } - ::Firebolt::TextToSpeech::ListVoicesResponse value() const override + [[nodiscard]] ::Firebolt::TextToSpeech::ListVoicesResponse value() const override { return ::Firebolt::TextToSpeech::ListVoicesResponse{ttsStatus_, voices_}; } @@ -68,7 +68,7 @@ class SpeechIdEvent : public Firebolt::JSON::NL_Json_Basic<::Firebolt::TextToSpe } speechId_ = json["speechid"].get(); } - ::Firebolt::TextToSpeech::SpeechIdEvent value() const override + [[nodiscard]] ::Firebolt::TextToSpeech::SpeechIdEvent value() const override { return ::Firebolt::TextToSpeech::SpeechIdEvent{speechId_}; } @@ -90,7 +90,7 @@ class SpeechResponse : public Firebolt::JSON::NL_Json_Basic<::Firebolt::TextToSp ttsStatus_ = json["TTS_Status"].get(); success_ = json["success"].get(); } - ::Firebolt::TextToSpeech::SpeechResponse value() const override + [[nodiscard]] ::Firebolt::TextToSpeech::SpeechResponse value() const override { return ::Firebolt::TextToSpeech::SpeechResponse{speechId_, ttsStatus_, success_}; } @@ -114,7 +114,7 @@ class SpeechStateResponse : public Firebolt::JSON::NL_Json_Basic<::Firebolt::Tex ttsStatus_ = json["TTS_Status"].get(); success_ = json["success"].get(); } - ::Firebolt::TextToSpeech::SpeechStateResponse value() const override + [[nodiscard]] ::Firebolt::TextToSpeech::SpeechStateResponse value() const override { return ::Firebolt::TextToSpeech::SpeechStateResponse{speechState_, ttsStatus_, success_}; } @@ -137,7 +137,7 @@ class TTSStatusResponse : public Firebolt::JSON::NL_Json_Basic<::Firebolt::TextT ttsStatus_ = json["TTS_Status"].get(); success_ = json["success"].get(); } - ::Firebolt::TextToSpeech::TTSStatusResponse value() const override + [[nodiscard]] ::Firebolt::TextToSpeech::TTSStatusResponse value() const override { return ::Firebolt::TextToSpeech::TTSStatusResponse{ttsStatus_, success_}; } diff --git a/src/json_types/videooutput.h b/src/json_types/videooutput.h new file mode 100644 index 0000000..9cc1398 --- /dev/null +++ b/src/json_types/videooutput.h @@ -0,0 +1,188 @@ +/** + * Copyright 2026 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +// +// ============================================================================ +// AUTO-GENERATED by firebolt-sdk-gen v0.1.0 — DO NOT EDIT +// ============================================================================ +#ifndef FIREBOLT_VIDEOOUTPUT_JSON_H +#define FIREBOLT_VIDEOOUTPUT_JSON_H + +#include "firebolt/videooutput.h" +#include +#include + +namespace Firebolt::VideoOutput::JsonData +{ + +NLOHMANN_JSON_SERIALIZE_ENUM(CecStateValue, { + {CecStateValue::Active, "active"}, + {CecStateValue::Inactive, "inactive"}, + {CecStateValue::Unsupported, "unsupported"}, + }) + +NLOHMANN_JSON_SERIALIZE_ENUM(ColorDepthValue, { + {ColorDepthValue::D0, "0"}, + {ColorDepthValue::D10, "10"}, + {ColorDepthValue::D12, "12"}, + {ColorDepthValue::D8, "8"}, + }) + +NLOHMANN_JSON_SERIALIZE_ENUM(ColorFormatValue, { + {ColorFormatValue::None, "none"}, + {ColorFormatValue::Rgb444, "rgb444"}, + {ColorFormatValue::Ycbcr420, "ycbcr420"}, + {ColorFormatValue::Ycbcr422, "ycbcr422"}, + {ColorFormatValue::Ycbcr444, "ycbcr444"}, + }) + +NLOHMANN_JSON_SERIALIZE_ENUM(DynamicRangeValue, { + {DynamicRangeValue::DolbyVision, "dolbyVision"}, + {DynamicRangeValue::Hdr10, "hdr10"}, + {DynamicRangeValue::Hdr10plus, "hdr10plus"}, + {DynamicRangeValue::Hlg, "hlg"}, + {DynamicRangeValue::None, "none"}, + {DynamicRangeValue::Sdr, "sdr"}, + }) + +NLOHMANN_JSON_SERIALIZE_ENUM(HdcpState, { + {HdcpState::Direct, "direct"}, + {HdcpState::Hdcp14, "hdcp1.4"}, + {HdcpState::Hdcp22, "hdcp2.2"}, + {HdcpState::None, "none"}, + }) + +NLOHMANN_JSON_SERIALIZE_ENUM(OutputColorimetry, { + {OutputColorimetry::Bt2020rgb, "bt2020rgb"}, + {OutputColorimetry::Bt2020ycc, "bt2020ycc"}, + {OutputColorimetry::Bt709, "bt709"}, + {OutputColorimetry::None, "none"}, + {OutputColorimetry::Oprgb, "oprgb"}, + }) + +NLOHMANN_JSON_SERIALIZE_ENUM(QuantizationRangeValue, { + {QuantizationRangeValue::Full, "full"}, + {QuantizationRangeValue::Limited, "limited"}, + {QuantizationRangeValue::None, "none"}, + }) + +NLOHMANN_JSON_SERIALIZE_ENUM(RefreshRateValue, { + {RefreshRateValue::R0, "0"}, + {RefreshRateValue::R23976, "23.976"}, + {RefreshRateValue::R24, "24"}, + {RefreshRateValue::R25, "25"}, + {RefreshRateValue::R2997, "29.97"}, + {RefreshRateValue::R30, "30"}, + {RefreshRateValue::R50, "50"}, + {RefreshRateValue::R5994, "59.94"}, + {RefreshRateValue::R60, "60"}, + }) + +inline const Firebolt::JSON::EnumType<::Firebolt::VideoOutput::CecStateValue> CecStateValueEnum({ + {"active", ::Firebolt::VideoOutput::CecStateValue::Active}, + {"inactive", ::Firebolt::VideoOutput::CecStateValue::Inactive}, + {"unsupported", ::Firebolt::VideoOutput::CecStateValue::Unsupported}, +}); + +inline const Firebolt::JSON::EnumType<::Firebolt::VideoOutput::ColorDepthValue> ColorDepthValueEnum({ + {"0", ::Firebolt::VideoOutput::ColorDepthValue::D0}, + {"10", ::Firebolt::VideoOutput::ColorDepthValue::D10}, + {"12", ::Firebolt::VideoOutput::ColorDepthValue::D12}, + {"8", ::Firebolt::VideoOutput::ColorDepthValue::D8}, +}); + +inline const Firebolt::JSON::EnumType<::Firebolt::VideoOutput::ColorFormatValue> ColorFormatValueEnum({ + {"none", ::Firebolt::VideoOutput::ColorFormatValue::None}, + {"rgb444", ::Firebolt::VideoOutput::ColorFormatValue::Rgb444}, + {"ycbcr420", ::Firebolt::VideoOutput::ColorFormatValue::Ycbcr420}, + {"ycbcr422", ::Firebolt::VideoOutput::ColorFormatValue::Ycbcr422}, + {"ycbcr444", ::Firebolt::VideoOutput::ColorFormatValue::Ycbcr444}, +}); + +inline const Firebolt::JSON::EnumType<::Firebolt::VideoOutput::DynamicRangeValue> DynamicRangeValueEnum({ + {"dolbyVision", ::Firebolt::VideoOutput::DynamicRangeValue::DolbyVision}, + {"hdr10", ::Firebolt::VideoOutput::DynamicRangeValue::Hdr10}, + {"hdr10plus", ::Firebolt::VideoOutput::DynamicRangeValue::Hdr10plus}, + {"hlg", ::Firebolt::VideoOutput::DynamicRangeValue::Hlg}, + {"none", ::Firebolt::VideoOutput::DynamicRangeValue::None}, + {"sdr", ::Firebolt::VideoOutput::DynamicRangeValue::Sdr}, +}); + +inline const Firebolt::JSON::EnumType<::Firebolt::VideoOutput::HdcpState> HdcpStateEnum({ + {"direct", ::Firebolt::VideoOutput::HdcpState::Direct}, + {"hdcp1.4", ::Firebolt::VideoOutput::HdcpState::Hdcp14}, + {"hdcp2.2", ::Firebolt::VideoOutput::HdcpState::Hdcp22}, + {"none", ::Firebolt::VideoOutput::HdcpState::None}, +}); + +inline const Firebolt::JSON::EnumType<::Firebolt::VideoOutput::OutputColorimetry> OutputColorimetryEnum({ + {"bt2020rgb", ::Firebolt::VideoOutput::OutputColorimetry::Bt2020rgb}, + {"bt2020ycc", ::Firebolt::VideoOutput::OutputColorimetry::Bt2020ycc}, + {"bt709", ::Firebolt::VideoOutput::OutputColorimetry::Bt709}, + {"none", ::Firebolt::VideoOutput::OutputColorimetry::None}, + {"oprgb", ::Firebolt::VideoOutput::OutputColorimetry::Oprgb}, +}); + +inline const Firebolt::JSON::EnumType<::Firebolt::VideoOutput::QuantizationRangeValue> QuantizationRangeValueEnum({ + {"full", ::Firebolt::VideoOutput::QuantizationRangeValue::Full}, + {"limited", ::Firebolt::VideoOutput::QuantizationRangeValue::Limited}, + {"none", ::Firebolt::VideoOutput::QuantizationRangeValue::None}, +}); + +inline const Firebolt::JSON::EnumType<::Firebolt::VideoOutput::RefreshRateValue> RefreshRateValueEnum({ + {"0", ::Firebolt::VideoOutput::RefreshRateValue::R0}, + {"23.976", ::Firebolt::VideoOutput::RefreshRateValue::R23976}, + {"24", ::Firebolt::VideoOutput::RefreshRateValue::R24}, + {"25", ::Firebolt::VideoOutput::RefreshRateValue::R25}, + {"29.97", ::Firebolt::VideoOutput::RefreshRateValue::R2997}, + {"30", ::Firebolt::VideoOutput::RefreshRateValue::R30}, + {"50", ::Firebolt::VideoOutput::RefreshRateValue::R50}, + {"59.94", ::Firebolt::VideoOutput::RefreshRateValue::R5994}, + {"60", ::Firebolt::VideoOutput::RefreshRateValue::R60}, +}); + +class VideoOutputResolution : public Firebolt::JSON::NL_Json_Basic<::Firebolt::VideoOutput::VideoOutputResolution> +{ +public: + void fromJson(const nlohmann::json& json) override + { + if (!checkRequiredFields(json, {"height", "width"})) + { + throw std::invalid_argument("Missing required fields in JSON"); + } + height_ = json["height"].get(); + width_ = json["width"].get(); + } + [[nodiscard]] ::Firebolt::VideoOutput::VideoOutputResolution value() const override + { + return ::Firebolt::VideoOutput::VideoOutputResolution{height_, width_}; + } + +private: + uint32_t height_{}; + uint32_t width_{}; +}; + +inline void to_json(nlohmann::json& j, const Firebolt::VideoOutput::VideoOutputResolution& v) +{ + j = nlohmann::json::object(); + j["height"] = v.height; + j["width"] = v.width; +} +} // namespace Firebolt::VideoOutput::JsonData + +#endif // FIREBOLT_VIDEOOUTPUT_JSON_H diff --git a/src/lifecycle_impl.cpp b/src/lifecycle_impl.cpp index 0380b56..5817d38 100644 --- a/src/lifecycle_impl.cpp +++ b/src/lifecycle_impl.cpp @@ -32,7 +32,7 @@ LifecycleImpl::LifecycleImpl(Firebolt::Helpers::IHelper& helper) { } -LifecycleImpl::~LifecycleImpl() {} +LifecycleImpl::~LifecycleImpl() = default; Result LifecycleImpl::close(const CloseType& reason) const { diff --git a/src/lifecycle_impl.h b/src/lifecycle_impl.h index 43a7796..6135381 100644 --- a/src/lifecycle_impl.h +++ b/src/lifecycle_impl.h @@ -39,16 +39,15 @@ class LifecycleImpl : public ILifecycle LifecycleImpl& operator=(const LifecycleImpl&) = delete; ~LifecycleImpl() override; - virtual Result close(const CloseType& type) const override; - virtual Result state() const override; + [[nodiscard]] Result close(const CloseType& type) const override; + [[nodiscard]] Result state() const override; Result subscribeOnStateChanged(std::function&)>&& notification) override; - virtual Result unsubscribe(SubscriptionId id) override; - virtual void unsubscribeAll() override; + Result unsubscribe(SubscriptionId id) override; + void unsubscribeAll() override; -private: private: Firebolt::Helpers::IHelper& helper_; Firebolt::Helpers::SubscriptionManager subscriptionManager_; diff --git a/src/localization_impl.cpp b/src/localization_impl.cpp index 454363f..378401f 100644 --- a/src/localization_impl.cpp +++ b/src/localization_impl.cpp @@ -43,6 +43,17 @@ Result LocalizationImpl::presentationLanguage() const return helper_.get("Localization.presentationLanguage"); } +Result LocalizationImpl::timeZone() const +{ + return helper_.get("Localization.timeZone"); +} + +Result LocalizationImpl::subscribeOnTimeZoneChanged(std::function&& notification) +{ + return subscriptionManager_.subscribe("Localization.onTimeZoneChanged", + std::move(notification)); +} + Result LocalizationImpl::subscribeOnCountryChanged(std::function&& notification) { return subscriptionManager_.subscribe("Localization.onCountryChanged", diff --git a/src/localization_impl.h b/src/localization_impl.h index 80e7cf5..3c05e14 100644 --- a/src/localization_impl.h +++ b/src/localization_impl.h @@ -33,9 +33,10 @@ class LocalizationImpl : public ILocalization ~LocalizationImpl() override = default; // Methods - Result country() const override; - Result> preferredAudioLanguages() const override; - Result presentationLanguage() const override; + [[nodiscard]] Result country() const override; + [[nodiscard]] Result> preferredAudioLanguages() const override; + [[nodiscard]] Result presentationLanguage() const override; + [[nodiscard]] Result timeZone() const override; // Events Result subscribeOnCountryChanged(std::function&& notification) override; @@ -43,6 +44,7 @@ class LocalizationImpl : public ILocalization std::function&)>&& notification) override; Result subscribeOnPresentationLanguageChanged(std::function&& notification) override; + Result subscribeOnTimeZoneChanged(std::function&& notification) override; Result unsubscribe(SubscriptionId id) override; void unsubscribeAll() override; diff --git a/src/metrics_impl.h b/src/metrics_impl.h index ace303b..1bccfa6 100644 --- a/src/metrics_impl.h +++ b/src/metrics_impl.h @@ -32,41 +32,42 @@ class MetricsImpl : public IMetrics ~MetricsImpl() override = default; - Result ready() const override; - Result signIn() const override; - Result signOut() const override; - Result startContent(const std::optional& entityId, - const std::optional agePolicy) const override; - Result stopContent(const std::optional& entityId, - const std::optional agePolicy) const override; - Result page(const std::string& pageId, const std::optional& agePolicy) const override; - Result error(const ErrorType type, const std::string& code, const std::string& description, - const bool visible, const std::optional>& parameters, - const std::optional& agePolicy) const override; - Result mediaLoadStart(const std::string& entityId, - const std::optional& agePolicy) const override; - Result mediaPlay(const std::string& entityId, - const std::optional& agePolicy) const override; - Result mediaPlaying(const std::string& entityId, - const std::optional& agePolicy) const override; - Result mediaPause(const std::string& entityId, - const std::optional& agePolicy) const override; - Result mediaWaiting(const std::string& entityId, - const std::optional& agePolicy) const override; - Result mediaSeeking(const std::string& entityId, const double target, - const std::optional& agePolicy) const override; - Result mediaSeeked(const std::string& entityId, const double position, - const std::optional& agePolicy) const override; - Result mediaRateChanged(const std::string& entityId, const double rate, - const std::optional& agePolicy) const override; - Result mediaRenditionChanged(const std::string& entityId, const unsigned bitrate, const unsigned width, - const unsigned height, const std::optional& profile, - const std::optional& agePolicy) const override; - Result mediaEnded(const std::string& entityId, - const std::optional& agePolicy) const override; - Result event(const std::string& schema, const std::string& data, - const std::optional& agePolicy) const override; - Result appInfo(const std::string& build) const override; + [[nodiscard]] Result ready() const override; + [[nodiscard]] Result signIn() const override; + [[nodiscard]] Result signOut() const override; + [[nodiscard]] Result startContent(const std::optional& entityId, + std::optional agePolicy) const override; + [[nodiscard]] Result stopContent(const std::optional& entityId, + std::optional agePolicy) const override; + [[nodiscard]] Result page(const std::string& pageId, + const std::optional& agePolicy) const override; + [[nodiscard]] Result error(ErrorType type, const std::string& code, const std::string& description, + bool visible, const std::optional>& parameters, + const std::optional& agePolicy) const override; + [[nodiscard]] Result mediaLoadStart(const std::string& entityId, + const std::optional& agePolicy) const override; + [[nodiscard]] Result mediaPlay(const std::string& entityId, + const std::optional& agePolicy) const override; + [[nodiscard]] Result mediaPlaying(const std::string& entityId, + const std::optional& agePolicy) const override; + [[nodiscard]] Result mediaPause(const std::string& entityId, + const std::optional& agePolicy) const override; + [[nodiscard]] Result mediaWaiting(const std::string& entityId, + const std::optional& agePolicy) const override; + [[nodiscard]] Result mediaSeeking(const std::string& entityId, double target, + const std::optional& agePolicy) const override; + [[nodiscard]] Result mediaSeeked(const std::string& entityId, double position, + const std::optional& agePolicy) const override; + [[nodiscard]] Result mediaRateChanged(const std::string& entityId, double rate, + const std::optional& agePolicy) const override; + [[nodiscard]] Result mediaRenditionChanged(const std::string& entityId, unsigned bitrate, unsigned width, + unsigned height, const std::optional& profile, + const std::optional& agePolicy) const override; + [[nodiscard]] Result mediaEnded(const std::string& entityId, + const std::optional& agePolicy) const override; + [[nodiscard]] Result event(const std::string& schema, const std::string& data, + const std::optional& agePolicy) const override; + [[nodiscard]] Result appInfo(const std::string& build) const override; private: Firebolt::Helpers::IHelper& helper_; diff --git a/src/network_impl.h b/src/network_impl.h index 178d22d..50ce47b 100644 --- a/src/network_impl.h +++ b/src/network_impl.h @@ -32,7 +32,7 @@ class NetworkImpl : public INetwork ~NetworkImpl() override = default; - Result connected() const override; + [[nodiscard]] Result connected() const override; Result subscribeOnConnectedChanged(std::function&& notification) override; diff --git a/src/presentation_impl.h b/src/presentation_impl.h index b37485e..26637d4 100644 --- a/src/presentation_impl.h +++ b/src/presentation_impl.h @@ -34,11 +34,11 @@ class PresentationImpl : public IPresentation ~PresentationImpl() override = default; - Result focused() const override; + [[nodiscard]] Result focused() const override; Result subscribeOnFocusedChanged(std::function&& notification) override; - virtual Result unsubscribe(SubscriptionId id) override; - virtual void unsubscribeAll() override; + Result unsubscribe(SubscriptionId id) override; + void unsubscribeAll() override; private: Firebolt::Helpers::IHelper& helper_; diff --git a/src/speechsynthesis_impl.cpp b/src/speechsynthesis_impl.cpp new file mode 100644 index 0000000..f924936 --- /dev/null +++ b/src/speechsynthesis_impl.cpp @@ -0,0 +1,102 @@ +/** + * Copyright 2026 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "speechsynthesis_impl.h" +#include "json_types/speechsynthesis.h" +#include +#include + +namespace Firebolt::SpeechSynthesis +{ +SpeechSynthesisImpl::SpeechSynthesisImpl(Firebolt::Helpers::IHelper& helper) + : helper_(helper), + subscriptionManager_(helper, this) +{ +} + +Result SpeechSynthesisImpl::speak(const std::string& text, std::optional callSign, + std::optional language, std::optional voice, + std::optional volume, std::optional rate, + std::optional pitch) const +{ + nlohmann::json params; + params["text"] = text; + + if (callSign) + { + params["callSign"] = *callSign; + } + if (language) + { + params["language"] = *language; + } + if (voice) + { + params["voice"] = *voice; + } + if (volume) + { + params["volume"] = *volume; + } + if (rate) + { + params["rate"] = *rate; + } + if (pitch) + { + params["pitch"] = *pitch; + } + + return helper_.get("SpeechSynthesis.speak", params); +} +Result> SpeechSynthesisImpl::voices() const +{ + return helper_.get>("SpeechSynthesis.voices"); +} + +Result +SpeechSynthesisImpl::subscribeOnVoicesChanged(std::function&)>&& notification) +{ + return subscriptionManager_.subscribe("SpeechSynthesis.onVoicesChanged", + std::move(notification)); +} +Result SpeechSynthesisImpl::cancel(UtteranceId id) const +{ + nlohmann::json params; + params["id"] = id; + return helper_.invoke("SpeechSynthesis.cancel", params); +} +Result SpeechSynthesisImpl::pause(UtteranceId id) const +{ + nlohmann::json params; + params["id"] = id; + return helper_.invoke("SpeechSynthesis.pause", params); +} +Result SpeechSynthesisImpl::resume(UtteranceId id) const +{ + nlohmann::json params; + params["id"] = id; + return helper_.invoke("SpeechSynthesis.resume", params); +} +Result +SpeechSynthesisImpl::subscribeOnUtteranceEvent(std::function&& notification) +{ + return subscriptionManager_.subscribe("SpeechSynthesis.onUtteranceEvent", + std::move(notification)); +} +} // namespace Firebolt::SpeechSynthesis \ No newline at end of file diff --git a/src/speechsynthesis_impl.h b/src/speechsynthesis_impl.h new file mode 100644 index 0000000..147d9f7 --- /dev/null +++ b/src/speechsynthesis_impl.h @@ -0,0 +1,56 @@ +/** + * Copyright 2026 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#pragma once + +#include +#include + +namespace Firebolt::SpeechSynthesis +{ +class SpeechSynthesisImpl : public ISpeechSynthesis +{ +public: + explicit SpeechSynthesisImpl(Firebolt::Helpers::IHelper& helper); + SpeechSynthesisImpl(const SpeechSynthesisImpl&) = delete; + SpeechSynthesisImpl& operator=(const SpeechSynthesisImpl&) = delete; + + ~SpeechSynthesisImpl() override = default; + + [[nodiscard]] Result speak(const std::string& text, std::optional callSign = std::nullopt, + std::optional language = std::nullopt, + std::optional voice = std::nullopt, + std::optional volume = std::nullopt, + std::optional rate = std::nullopt, + std::optional pitch = std::nullopt) const override; + [[nodiscard]] Result> voices() const override; + + [[nodiscard]] Result + subscribeOnVoicesChanged(std::function&)>&& notification) override; + + [[nodiscard]] Result cancel(UtteranceId id) const override; + [[nodiscard]] Result pause(UtteranceId id) const override; + [[nodiscard]] Result resume(UtteranceId id) const override; + [[nodiscard]] Result + subscribeOnUtteranceEvent(std::function&& notification) override; + +private: + Firebolt::Helpers::IHelper& helper_; + Firebolt::Helpers::SubscriptionManager subscriptionManager_; +}; +} // namespace Firebolt::SpeechSynthesis \ No newline at end of file diff --git a/src/stats_impl.cpp b/src/stats_impl.cpp index a61e413..fa16a80 100644 --- a/src/stats_impl.cpp +++ b/src/stats_impl.cpp @@ -31,7 +31,7 @@ StatsImpl::StatsImpl(Firebolt::Helpers::IHelper& helper) { } -StatsImpl::~StatsImpl() {} +StatsImpl::~StatsImpl() = default; Result StatsImpl::memoryUsage() const { diff --git a/src/stats_impl.h b/src/stats_impl.h index fa693f6..f662375 100644 --- a/src/stats_impl.h +++ b/src/stats_impl.h @@ -32,7 +32,7 @@ class StatsImpl : public IStats StatsImpl& operator=(const StatsImpl&) = delete; ~StatsImpl() override; - virtual Result memoryUsage() const override; + [[nodiscard]] Result memoryUsage() const override; private: Firebolt::Helpers::IHelper& helper_; diff --git a/src/texttospeech_impl.cpp b/src/texttospeech_impl.cpp index d02d17d..7463b5a 100644 --- a/src/texttospeech_impl.cpp +++ b/src/texttospeech_impl.cpp @@ -34,10 +34,38 @@ Result TextToSpeechImpl::listVoices(const std::string& langu return helper_.get("TextToSpeech.listvoices", params); } -Result TextToSpeechImpl::speak(const std::string& text) const +Result TextToSpeechImpl::speak(const std::string& text, std::optional callSign, + std::optional language, std::optional voice, + std::optional volume, std::optional rate, + std::optional pitch) const { nlohmann::json params; params["text"] = text; + if (callSign) + { + params["callSign"] = *callSign; + } + if (language) + { + params["language"] = *language; + } + if (voice) + { + params["voice"] = *voice; + } + if (volume) + { + params["volume"] = *volume; + } + if (rate) + { + params["rate"] = *rate; + } + if (pitch) + { + params["pitch"] = *pitch; + } + return helper_.get("TextToSpeech.speak", params); } diff --git a/src/texttospeech_impl.h b/src/texttospeech_impl.h index a4aef02..7d8f3d8 100644 --- a/src/texttospeech_impl.h +++ b/src/texttospeech_impl.h @@ -32,12 +32,16 @@ class TextToSpeechImpl : public ITextToSpeech ~TextToSpeechImpl() override = default; - Result listVoices(const std::string& language) const override; - Result speak(const std::string& text) const override; - Result pause(SpeechId speechId) const override; - Result resume(SpeechId speechId) const override; - Result cancel(SpeechId speechId) const override; - Result getSpeechState(SpeechId speechId) const override; + [[nodiscard]] Result listVoices(const std::string& language) const override; + [[nodiscard]] Result + speak(const std::string& text, std::optional callSign = std::nullopt, + std::optional language = std::nullopt, std::optional voice = std::nullopt, + std::optional volume = std::nullopt, std::optional rate = std::nullopt, + std::optional pitch = std::nullopt) const override; + [[nodiscard]] Result pause(SpeechId speechId) const override; + [[nodiscard]] Result resume(SpeechId speechId) const override; + [[nodiscard]] Result cancel(SpeechId speechId) const override; + [[nodiscard]] Result getSpeechState(SpeechId speechId) const override; Result subscribeOnWillSpeak(std::function&& notification) override; Result subscribeOnSpeechStart(std::function&& notification) override; diff --git a/src/videooutput_impl.cpp b/src/videooutput_impl.cpp new file mode 100644 index 0000000..73242b3 --- /dev/null +++ b/src/videooutput_impl.cpp @@ -0,0 +1,113 @@ +/** + * Copyright 2026 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +// +// ============================================================================ +// AUTO-GENERATED by firebolt-sdk-gen v0.1.0 — DO NOT EDIT +// ============================================================================ +#include "videooutput_impl.h" +#include "json_types/videooutput.h" +#include + +namespace Firebolt::VideoOutput +{ +VideoOutputImpl::VideoOutputImpl(Firebolt::Helpers::IHelper& helper) + : helper_(helper), + subscriptionManager_(helper, this) +{ +} + +Result VideoOutputImpl::resolution() const +{ + return helper_.get("VideoOutput.resolution"); +} +Result +VideoOutputImpl::subscribeOnResolutionChanged(std::function&& notification) +{ + return subscriptionManager_.subscribe("VideoOutput.onResolutionChanged", + std::move(notification)); +} + +Result VideoOutputImpl::hdcp() const +{ + return helper_.get, HdcpState>("VideoOutput.hdcp"); +} +Result VideoOutputImpl::subscribeOnHdcpChanged(std::function&& notification) +{ + return subscriptionManager_.subscribe>("VideoOutput.onHdcpChanged", + std::move(notification)); +} + +Result VideoOutputImpl::cecState() const +{ + return helper_.get, CecStateValue>("VideoOutput.cecState"); +} +Result VideoOutputImpl::subscribeOnCecStateChanged(std::function&& notification) +{ + return subscriptionManager_.subscribe>("VideoOutput.onCecStateChanged", + std::move(notification)); +} + +Result VideoOutputImpl::refreshRate() const +{ + return helper_.get, RefreshRateValue>("VideoOutput.refreshRate"); +} +Result +VideoOutputImpl::subscribeOnRefreshRateChanged(std::function&& notification) +{ + return subscriptionManager_ + .subscribe>("VideoOutput.onRefreshRateChanged", + std::move(notification)); +} + +Result VideoOutputImpl::colorDepth() const +{ + return helper_.get, ColorDepthValue>("VideoOutput.colorDepth"); +} + +Result VideoOutputImpl::colorFormat() const +{ + return helper_.get, ColorFormatValue>("VideoOutput.colorFormat"); +} + +Result VideoOutputImpl::colorimetry() const +{ + return helper_.get, OutputColorimetry>("VideoOutput.colorimetry"); +} + +Result VideoOutputImpl::dynamicRange() const +{ + return helper_.get, DynamicRangeValue>("VideoOutput.dynamicRange"); +} + +Result VideoOutputImpl::quantizationRange() const +{ + return helper_.get, QuantizationRangeValue>( + "VideoOutput.quantizationRange"); +} + +Result VideoOutputImpl::unsubscribe(SubscriptionId id) +{ + return subscriptionManager_.unsubscribe(id); +} + +void VideoOutputImpl::unsubscribeAll() +{ + subscriptionManager_.unsubscribeAll(); +} + +} // namespace Firebolt::VideoOutput diff --git a/src/videooutput_impl.h b/src/videooutput_impl.h new file mode 100644 index 0000000..8d2b4ec --- /dev/null +++ b/src/videooutput_impl.h @@ -0,0 +1,71 @@ +/** + * Copyright 2026 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once +#ifndef FIREBOLT_VIDEOOUTPUT_IMPL_H +#define FIREBOLT_VIDEOOUTPUT_IMPL_H + +#include "firebolt/videooutput.h" +#include + +namespace Firebolt::VideoOutput +{ + +class VideoOutputImpl : public IVideoOutput +{ +public: + explicit VideoOutputImpl(Firebolt::Helpers::IHelper& helper); + VideoOutputImpl(const VideoOutputImpl&) = delete; + VideoOutputImpl& operator=(const VideoOutputImpl&) = delete; + VideoOutputImpl(VideoOutputImpl&&) = delete; + VideoOutputImpl& operator=(VideoOutputImpl&&) = delete; + ~VideoOutputImpl() override = default; + [[nodiscard]] Result resolution() const override; + Result + subscribeOnResolutionChanged(std::function&& notification) override; + + [[nodiscard]] Result hdcp() const override; + Result subscribeOnHdcpChanged(std::function&& notification) override; + + [[nodiscard]] Result cecState() const override; + Result subscribeOnCecStateChanged(std::function&& notification) override; + + [[nodiscard]] Result refreshRate() const override; + Result + subscribeOnRefreshRateChanged(std::function&& notification) override; + + [[nodiscard]] Result colorDepth() const override; + + [[nodiscard]] Result colorFormat() const override; + + [[nodiscard]] Result colorimetry() const override; + + [[nodiscard]] Result dynamicRange() const override; + + [[nodiscard]] Result quantizationRange() const override; + + Result unsubscribe(SubscriptionId id) override; + void unsubscribeAll() override; + +private: + Firebolt::Helpers::IHelper& helper_; + Firebolt::Helpers::SubscriptionManager subscriptionManager_; +}; + +} // namespace Firebolt::VideoOutput + +#endif // FIREBOLT_VIDEOOUTPUT_IMPL_H diff --git a/test/api_test_app/apis/actionsDemo.cpp b/test/api_test_app/apis/actionsDemo.cpp new file mode 100644 index 0000000..31b982f --- /dev/null +++ b/test/api_test_app/apis/actionsDemo.cpp @@ -0,0 +1,106 @@ +/** + * Copyright 2026 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "actionsDemo.h" +#include +#include +#include +#include + +using namespace Firebolt; +using namespace Firebolt::Actions; + +ActionsDemo::ActionsDemo() + : DemoBase("Actions") +{ + methods_.push_back("Actions.intent"); + methods_.push_back("Actions.start"); + methods_.push_back("Actions.onIntent"); + methods_.push_back("Actions.unsubscribe"); + methods_.push_back("Actions.unsubscribeAll"); +} + +void ActionsDemo::runOption(const std::string& method) +{ + std::cout << "Running Actions method: " << method << std::endl; + + if (method == "Actions.intent") + { + auto r = Firebolt::IFireboltAccessor::Instance().ActionsInterface().intent(); + if (succeed(r)) + { + std::cout << "Current Intent - action: " << r->intent.action << ", source: " + << (r->intent.context && r->intent.context->source ? *r->intent.context->source : "(none)") + << ", intentId: " << r->intentId << std::endl; + } + } + else if (method == "Actions.start") + { + std::string actionStr = paramFromConsole("action", "pre-load"); + std::string sourceStr = paramFromConsole("context.source (leave empty to skip)", "system"); + std::string handlerAppIdStr = paramFromConsole("handlerAppId (leave empty to skip)", ""); + std::optional handlerAppId; + if (!handlerAppIdStr.empty()) + handlerAppId = handlerAppIdStr; + Firebolt::Actions::IntentData intentData{actionStr}; + if (!sourceStr.empty()) + intentData.context = Firebolt::Actions::IntentContext{sourceStr}; + auto r = Firebolt::IFireboltAccessor::Instance().ActionsInterface().start(intentData, handlerAppId); + if (succeed(r)) + { + std::cout << "Actions.start: Success" << std::endl; + } + } + else if (method == "Actions.onIntent") + { + auto callback = [&](const Intent& payload) + { + std::cout << "Intent received - action: " << payload.intent.action << ", source: " + << (payload.intent.context && payload.intent.context->source ? *payload.intent.context->source + : "(none)") + << ", intentId: " << payload.intentId << std::endl; + }; + auto r = Firebolt::IFireboltAccessor::Instance().ActionsInterface().subscribeOnIntent(std::move(callback)); + if (succeed(r)) + { + std::cout << "Subscribed to Actions.onIntent with Subscription ID: " << *r << std::endl; + } + } + else if (method == "Actions.unsubscribe") + { + std::string idStr = paramFromConsole("subscription ID", "0"); + SubscriptionId id = 0; + try + { + id = static_cast(std::stoul(idStr)); + } + catch (const std::exception&) + { + } + auto r = Firebolt::IFireboltAccessor::Instance().ActionsInterface().unsubscribe(id); + if (succeed(r)) + { + std::cout << "Unsubscribed from Actions subscription " << id << std::endl; + } + } + else if (method == "Actions.unsubscribeAll") + { + Firebolt::IFireboltAccessor::Instance().ActionsInterface().unsubscribeAll(); + std::cout << "Unsubscribed from all Actions subscriptions" << std::endl; + } +} diff --git a/test/api_test_app/apis/actionsDemo.h b/test/api_test_app/apis/actionsDemo.h new file mode 100644 index 0000000..ae564cc --- /dev/null +++ b/test/api_test_app/apis/actionsDemo.h @@ -0,0 +1,30 @@ +/** + * Copyright 2026 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#pragma once + +#include "utils.h" +#include + +class ActionsDemo : public DemoBase +{ +public: + ActionsDemo(); + ~ActionsDemo() = default; + void runOption(const std::string& method) override; +}; diff --git a/test/api_test_app/apis/deviceDemo.cpp b/test/api_test_app/apis/deviceDemo.cpp index 94c9cb2..ef1a99f 100644 --- a/test/api_test_app/apis/deviceDemo.cpp +++ b/test/api_test_app/apis/deviceDemo.cpp @@ -33,6 +33,7 @@ DeviceDemo::DeviceDemo() { methods_.push_back("Device.chipsetId"); methods_.push_back("Device.deviceClass"); + methods_.push_back("Device.dolbyAtmosExperienceAvailable"); methods_.push_back("Device.hdr"); methods_.push_back("Device.timeInActiveState"); methods_.push_back("Device.uid"); @@ -60,6 +61,46 @@ void DeviceDemo::runOption(const std::string& method) << std::endl; } } + else if (method == "Device.osName") + { + auto r = Firebolt::IFireboltAccessor::Instance().DeviceInterface().osName(); + if (succeed(r)) + { + std::cout << "Device OS Name: " << *r << std::endl; + } + } + else if (method == "Device.setOsName") + { + auto r = Firebolt::IFireboltAccessor::Instance().DeviceInterface().setOsName("Linux"); + if (succeed(r)) + { + std::cout << "Device OS Name set successfully" << std::endl; + } + } + else if (method == "Device.osVersion") + { + auto r = Firebolt::IFireboltAccessor::Instance().DeviceInterface().osVersion(); + if (succeed(r)) + { + std::cout << "Device OS Version: " << *r << std::endl; + } + } + else if (method == "Device.setOsVersion") + { + auto r = Firebolt::IFireboltAccessor::Instance().DeviceInterface().setOsVersion("5.15.0"); + if (succeed(r)) + { + std::cout << "Device OS Version set successfully" << std::endl; + } + } + else if (method == "Device.firmware") + { + auto r = Firebolt::IFireboltAccessor::Instance().DeviceInterface().firmware(); + if (succeed(r)) + { + std::cout << "Device Firmware: " << *r << std::endl; + } + } else if (method == "Device.hdr") { auto r = Firebolt::IFireboltAccessor::Instance().DeviceInterface().hdr(); @@ -93,4 +134,12 @@ void DeviceDemo::runOption(const std::string& method) std::cout << "Device Uptime (seconds): " << *r << std::endl; } } + else if (method == "Device.dolbyAtmosExperienceAvailable") + { + auto r = Firebolt::IFireboltAccessor::Instance().DeviceInterface().dolbyAtmosExperienceAvailable(); + if (succeed(r)) + { + std::cout << std::boolalpha << "Dolby Atmos Experience Available: " << *r << std::endl; + } + } } diff --git a/test/api_test_app/apis/discoveryDemo.cpp b/test/api_test_app/apis/discoveryDemo.cpp index 932785c..8c8d0ea 100644 --- a/test/api_test_app/apis/discoveryDemo.cpp +++ b/test/api_test_app/apis/discoveryDemo.cpp @@ -87,7 +87,7 @@ void DiscoveryDemo::runOption(const std::string& method) watchedOn, agePolicyOpt); if (succeed(r)) { - std::cout << "Discovery.watchedV2: " << (*r ? "true" : "false") << std::endl; + std::cout << "Discovery.watchedV2: Success" << std::endl; } } } diff --git a/test/api_test_app/apis/lifecycleDemo.cpp b/test/api_test_app/apis/lifecycleDemo.cpp index a550527..f05c133 100644 --- a/test/api_test_app/apis/lifecycleDemo.cpp +++ b/test/api_test_app/apis/lifecycleDemo.cpp @@ -73,15 +73,17 @@ void LifecycleDemo::runOption(const std::string& method) Firebolt::IFireboltAccessor::Instance().LifecycleInterface().subscribeOnStateChanged(std::move(callback)); if (succeed(r)) { + lastSubscriptionId_ = *r; std::cout << "Subscribed to Lifecycle state changes with Subscription ID: " << *r << std::endl; } } else if (method == "Lifecycle2.unsubscribe") { - SubscriptionId id = 0; + SubscriptionId id = lastSubscriptionId_; try { - id = static_cast(std::stoul(paramFromConsole("Subscription ID to unsubscribe", "0"))); + id = static_cast( + std::stoul(paramFromConsole("Subscription ID to unsubscribe", std::to_string(lastSubscriptionId_)))); } catch (const std::exception&) { diff --git a/test/api_test_app/apis/lifecycleDemo.h b/test/api_test_app/apis/lifecycleDemo.h index 9ccf509..d423741 100644 --- a/test/api_test_app/apis/lifecycleDemo.h +++ b/test/api_test_app/apis/lifecycleDemo.h @@ -31,4 +31,5 @@ class LifecycleDemo : public DemoBase private: Firebolt::Lifecycle::LifecycleState currentState_; + Firebolt::SubscriptionId lastSubscriptionId_{0}; }; diff --git a/test/api_test_app/apis/localizationDemo.cpp b/test/api_test_app/apis/localizationDemo.cpp index 19bd772..4fd9ee7 100644 --- a/test/api_test_app/apis/localizationDemo.cpp +++ b/test/api_test_app/apis/localizationDemo.cpp @@ -30,6 +30,7 @@ LocalizationDemo::LocalizationDemo() methods_.push_back("Localization.country"); methods_.push_back("Localization.preferredAudioLanguages"); methods_.push_back("Localization.presentationLanguage"); + methods_.push_back("Localization.timeZone"); } void LocalizationDemo::runOption(const std::string& method) @@ -63,4 +64,12 @@ void LocalizationDemo::runOption(const std::string& method) std::cout << "Presentation Language: " << *r << std::endl; } } + else if (method == "Localization.timeZone") + { + auto r = Firebolt::IFireboltAccessor::Instance().LocalizationInterface().timeZone(); + if (succeed(r)) + { + std::cout << "TimeZone: " << *r << std::endl; + } + } } diff --git a/test/api_test_app/apis/statsDemo.cpp b/test/api_test_app/apis/statsDemo.cpp index cc2524f..6fe5047 100644 --- a/test/api_test_app/apis/statsDemo.cpp +++ b/test/api_test_app/apis/statsDemo.cpp @@ -38,8 +38,8 @@ void StatsDemo::runOption(const std::string& method) auto r = Firebolt::IFireboltAccessor::Instance().StatsInterface().memoryUsage(); if (succeed(r)) { - std::cout << "User Memory Used: " << r->userMemoryUsed << " / " << r->userMemoryLimit << std::endl; - std::cout << "GPU Memory Used: " << r->gpuMemoryUsed << " / " << r->gpuMemoryLimit << std::endl; + std::cout << "User Memory Used (bytes): " << r->userMemoryUsed << " / " << r->userMemoryLimit << std::endl; + std::cout << "GPU Memory Used (bytes): " << r->gpuMemoryUsed << " / " << r->gpuMemoryLimit << std::endl; } } } diff --git a/test/api_test_app/main.cpp b/test/api_test_app/main.cpp index 6e7f5e7..a6c2f05 100644 --- a/test/api_test_app/main.cpp +++ b/test/api_test_app/main.cpp @@ -17,6 +17,7 @@ */ #include "accessibilityDemo.h" +#include "actionsDemo.h" #include "advertisingDemo.h" #include "deviceDemo.h" #include "discoveryDemo.h" @@ -157,6 +158,7 @@ int main(int argc, char** argv) std::vector> interfaces; interfaces.emplace_back(std::make_unique()); + interfaces.emplace_back(std::make_unique()); interfaces.emplace_back(std::make_unique()); interfaces.emplace_back(std::make_unique()); interfaces.emplace_back(std::make_unique()); @@ -169,7 +171,29 @@ int main(int argc, char** argv) interfaces.emplace_back(std::make_unique()); interfaces.emplace_back(std::make_unique()); - if (!isatty(fileno(stdin))) + if (appConfig.autoRun) + { + int failures = 0; + for (auto& interface : interfaces) + { + std::cout << "Auto-running interface: " << interface->name() << std::endl; + + for (auto& method : interface->methods()) + { + std::cout << "Auto-running method: " << method << std::endl; + interface->runOption(method); + } + failures += interface->failureCount(); + } + if (failures > 0) + { + std::cout << "FAILED: " << failures << " method(s) returned errors" << std::endl; + Firebolt::IFireboltAccessor::Instance().Disconnect(); + return 1; + } + std::cout << "All methods succeeded" << std::endl; + } + else if (!isatty(fileno(stdin))) { appConfig.autoRun = true; std::string line; @@ -198,19 +222,6 @@ int main(int argc, char** argv) } } } - else if (appConfig.autoRun) - { - for (auto& interface : interfaces) - { - std::cout << "Auto-running interface: " << interface->name() << std::endl; - - for (auto& method : interface->methods()) - { - std::cout << "Auto-running method: " << method << std::endl; - interface->runOption(method); - } - } - } else { std::vector interfaceNames; diff --git a/test/api_test_app/utils.h b/test/api_test_app/utils.h index 7a3d72e..ec94590 100644 --- a/test/api_test_app/utils.h +++ b/test/api_test_app/utils.h @@ -47,20 +47,23 @@ class DemoBase std::string name() const { return name_; } virtual void runOption(const std::string& method) = 0; const std::vector& methods() const { return methods_; } + int failureCount() const { return failureCount_; } protected: - template bool succeed(const Firebolt::Result& result) const + template bool succeed(const Firebolt::Result& result) { if (result) { return true; } + ++failureCount_; std::cout << "Error: " << static_cast(result.error()) << std::endl; return false; } std::string name_; std::vector methods_; + int failureCount_ = 0; }; template T chooseEnumFromList(const Firebolt::JSON::EnumType& enumType, const std::string& prompt) diff --git a/test/component/accessibilityTest.cpp b/test/component/accessibilityTest.cpp index 856aca7..cf7d8db 100644 --- a/test/component/accessibilityTest.cpp +++ b/test/component/accessibilityTest.cpp @@ -50,7 +50,7 @@ TEST_F(AccessibilityCTest, SubscribeOnAudioDescriptionChanged) [&](const bool& enabled) { - std::cout << "[Subscription] Accessibility audio description changed" << std::endl; + std::cout << "[Subscription] Accessibility audio description changed" << '\n'; EXPECT_EQ(enabled, true); { @@ -83,7 +83,7 @@ TEST_F(AccessibilityCTest, SubscribeOnClosedCaptionsSettingsChanged) auto id = Firebolt::IFireboltAccessor::Instance().AccessibilityInterface().subscribeOnClosedCaptionsSettingsChanged( [&](const Firebolt::Accessibility::ClosedCaptionsSettings& settings) { - std::cout << "[Subscription] Accessibility closed captions settings changed" << std::endl; + std::cout << "[Subscription] Accessibility closed captions settings changed" << '\n'; EXPECT_EQ(settings.enabled, true); EXPECT_EQ(settings.preferredLanguages.size(), 2); @@ -126,7 +126,7 @@ TEST_F(AccessibilityCTest, SubscribeOnHighContrastUIChanged) auto id = Firebolt::IFireboltAccessor::Instance().AccessibilityInterface().subscribeOnHighContrastUIChanged( [&](const bool& enabled) { - std::cout << "[Subscription] Accessibility high contrast UI changed" << std::endl; + std::cout << "[Subscription] Accessibility high contrast UI changed" << '\n'; EXPECT_EQ(enabled, true); { @@ -161,7 +161,7 @@ TEST_F(AccessibilityCTest, SubscribeOnVoiceGuidanceSettingsChanged) auto id = Firebolt::IFireboltAccessor::Instance().AccessibilityInterface().subscribeOnVoiceGuidanceSettingsChanged( [&](const Firebolt::Accessibility::VoiceGuidanceSettings& settings) { - std::cout << "[Subscription] Accessibility voice guidance settings changed" << std::endl; + std::cout << "[Subscription] Accessibility voice guidance settings changed" << '\n'; EXPECT_EQ(settings.enabled, true); EXPECT_EQ(settings.rate, 0.8); diff --git a/test/component/actionsGeneratedTest.cpp b/test/component/actionsGeneratedTest.cpp index 38a5356..ceb55a7 100644 --- a/test/component/actionsGeneratedTest.cpp +++ b/test/component/actionsGeneratedTest.cpp @@ -36,15 +36,23 @@ TEST_F(ActionsGeneratedCTest, Intent) { auto result = Firebolt::IFireboltAccessor::Instance().ActionsInterface().intent(); ASSERT_TRUE(result) << toError(result); - EXPECT_EQ(*result, "launch"); + EXPECT_EQ(result->intent.action, "pre-load"); + ASSERT_TRUE(result->intent.context); + ASSERT_TRUE(result->intent.context->source); + EXPECT_EQ(*result->intent.context->source, "system"); + EXPECT_EQ(result->intentId, 0U); } TEST_F(ActionsGeneratedCTest, SubscribeOnIntent) { auto id = Firebolt::IFireboltAccessor::Instance().ActionsInterface().subscribeOnIntent( - [&](const std::string& intent) + [&](const Firebolt::Actions::Intent& payload) { - EXPECT_EQ(intent, "launch"); + EXPECT_EQ(payload.intent.action, "pre-load"); + ASSERT_TRUE(payload.intent.context); + ASSERT_TRUE(payload.intent.context->source); + EXPECT_EQ(*payload.intent.context->source, "system"); + EXPECT_EQ(payload.intentId, 0U); { std::lock_guard lock(mtx); eventReceived = true; @@ -55,9 +63,16 @@ TEST_F(ActionsGeneratedCTest, SubscribeOnIntent) ASSERT_TRUE(id) << toError(id); verifyEventSubscription(id); - triggerEvent("Actions.onIntent", R"("launch")"); + triggerEvent("Actions.onIntent", R"({"intent":{"action":"pre-load","context":{"source":"system"}},"intentId":0})"); verifyEventReceived(mtx, cv, eventReceived); auto result = Firebolt::IFireboltAccessor::Instance().ActionsInterface().unsubscribe(id.value()); verifyUnsubscribeResult(result); } + +TEST_F(ActionsGeneratedCTest, Start) +{ + auto result = Firebolt::IFireboltAccessor::Instance().ActionsInterface().start( + Firebolt::Actions::IntentData{"pre-load", Firebolt::Actions::IntentContext{{"system"}}}); + ASSERT_TRUE(result) << toError(result); +} diff --git a/test/component/deviceTest.cpp b/test/component/deviceTest.cpp index 289a16a..00163ac 100644 --- a/test/component/deviceTest.cpp +++ b/test/component/deviceTest.cpp @@ -50,6 +50,41 @@ TEST_F(DeviceCTest, DeviceClass) ASSERT_TRUE(result) << "DeviceImpl::deviceClass() returned an error"; EXPECT_EQ(static_cast(*result), static_cast(Firebolt::Device::JsonData::DeviceClassEnum.at(expectedValue))); } +TEST_F(DeviceCTest, OsName) +{ + auto expectedValue = jsonEngine.get_value("Device.osName"); + auto result = Firebolt::IFireboltAccessor::Instance().DeviceInterface().osName(); + ASSERT_TRUE(result) << "DeviceImpl::osName() returned an error"; + EXPECT_EQ(*result, expectedValue); +} + +TEST_F(DeviceCTest, SetOsName) +{ + auto result = Firebolt::IFireboltAccessor::Instance().DeviceInterface().setOsName("Linux"); + ASSERT_TRUE(result) << "DeviceImpl::setOsName() returned an error"; +} + +TEST_F(DeviceCTest, OsVersion) +{ + auto expectedValue = jsonEngine.get_value("Device.osVersion"); + auto result = Firebolt::IFireboltAccessor::Instance().DeviceInterface().osVersion(); + ASSERT_TRUE(result) << "DeviceImpl::osVersion() returned an error"; + EXPECT_EQ(*result, expectedValue); +} + +TEST_F(DeviceCTest, SetOsVersion) +{ + auto result = Firebolt::IFireboltAccessor::Instance().DeviceInterface().setOsVersion("5.15.0"); + ASSERT_TRUE(result) << "DeviceImpl::setOsVersion() returned an error"; +} + +TEST_F(DeviceCTest, Firmware) +{ + auto expectedValue = jsonEngine.get_value("Device.firmware"); + auto result = Firebolt::IFireboltAccessor::Instance().DeviceInterface().firmware(); + ASSERT_TRUE(result) << "DeviceImpl::firmware() returned an error"; + EXPECT_EQ(*result, expectedValue); +} TEST_F(DeviceCTest, Hdr) { @@ -69,7 +104,7 @@ TEST_F(DeviceCTest, TimeInActiveState) ASSERT_TRUE(result) << "DeviceImpl::timeInActiveState() returned an error"; if (expectedValue.empty()) { - std::cout << "[ !!! ] Expected is empty, received: " << *result << std::endl; + std::cout << "[ !!! ] Expected is empty, received: " << *result << '\n'; return; } EXPECT_EQ(*result, expectedValue); @@ -90,7 +125,7 @@ TEST_F(DeviceCTest, Uptime) ASSERT_TRUE(result) << "DeviceImpl::uptime() returned an error"; if (expectedValue.empty()) { - std::cout << "[ !!! ] Expected is empty, received: " << *result << std::endl; + std::cout << "[ !!! ] Expected is empty, received: " << *result << '\n'; return; } EXPECT_EQ(*result, expectedValue); @@ -101,7 +136,7 @@ TEST_F(DeviceCTest, SubscribeOnHdrChanged) auto id = Firebolt::IFireboltAccessor::Instance().DeviceInterface().subscribeOnHdrChanged( [&](const Firebolt::Device::HDRFormat& value) { - std::cout << "[Subscription] Device HDR changed" << std::endl; + std::cout << "[Subscription] Device HDR changed" << '\n'; EXPECT_EQ(value.hdr10, true); EXPECT_EQ(value.hdr10Plus, true); EXPECT_EQ(value.dolbyVision, true); @@ -121,3 +156,34 @@ TEST_F(DeviceCTest, SubscribeOnHdrChanged) auto result = Firebolt::IFireboltAccessor::Instance().DeviceInterface().unsubscribe(id.value()); verifyUnsubscribeResult(result); } + +TEST_F(DeviceCTest, DolbyAtmosExperienceAvailable) +{ + auto expectedValue = jsonEngine.get_value("Device.dolbyAtmosExperienceAvailable"); + auto result = Firebolt::IFireboltAccessor::Instance().DeviceInterface().dolbyAtmosExperienceAvailable(); + ASSERT_TRUE(result) << "DeviceImpl::dolbyAtmosExperienceAvailable() returned an error"; + EXPECT_EQ(*result, expectedValue.get()); +} + +TEST_F(DeviceCTest, SubscribeOnDolbyAtmosExperienceAvailableChanged) +{ + auto id = Firebolt::IFireboltAccessor::Instance().DeviceInterface().subscribeOnDolbyAtmosExperienceAvailableChanged( + [&](const bool& value) + { + std::cout << "[Subscription] Device Dolby Atmos experience availability changed" << '\n'; + EXPECT_EQ(value, true); + { + std::lock_guard lock(mtx); + eventReceived = true; + } + cv.notify_one(); + }); + + verifyEventSubscription(id); + + triggerEvent("Device.onDolbyAtmosExperienceAvailableChanged", R"({ "value": true })"); + verifyEventReceived(mtx, cv, eventReceived); + + auto result = Firebolt::IFireboltAccessor::Instance().DeviceInterface().unsubscribe(id.value()); + verifyUnsubscribeResult(result); +} diff --git a/test/component/discoveryTest.cpp b/test/component/discoveryTest.cpp index bdab3a5..bb04cca 100644 --- a/test/component/discoveryTest.cpp +++ b/test/component/discoveryTest.cpp @@ -30,7 +30,7 @@ class DiscoveryCTest : public ::testing::Test TEST_F(DiscoveryCTest, Watched) { auto expectedValue = jsonEngine.get_value("Discovery.watched"); - auto result = Firebolt::IFireboltAccessor::Instance().DiscoveryInterface().watched("entity123", 0.75f, true, + auto result = Firebolt::IFireboltAccessor::Instance().DiscoveryInterface().watched("entity123", 0.75F, true, "2024-10-01T12:00:00Z", Firebolt::AgePolicy::ADULT); ASSERT_TRUE(result) << "Failed to call watched"; @@ -40,10 +40,8 @@ TEST_F(DiscoveryCTest, Watched) TEST_F(DiscoveryCTest, WatchedV2) { - auto expectedValue = jsonEngine.get_value("Discovery.watchedV2"); - auto result = Firebolt::IFireboltAccessor::Instance().DiscoveryInterface().watchedV2("entity123", 0.75f, true, + auto result = Firebolt::IFireboltAccessor::Instance().DiscoveryInterface().watchedV2("entity123", 0.75F, true, "2024-10-01T12:00:00Z", Firebolt::AgePolicy::ADULT); ASSERT_TRUE(result) << "Failed to call watchedV2"; - EXPECT_EQ(*result, expectedValue.get()); } diff --git a/test/component/lifecycleTest.cpp b/test/component/lifecycleTest.cpp index d6504c9..4047461 100644 --- a/test/component/lifecycleTest.cpp +++ b/test/component/lifecycleTest.cpp @@ -59,9 +59,9 @@ TEST_F(LifecycleCTest, subscribeOnState_JSON_RPC_compliant) auto id = Firebolt::IFireboltAccessor::Instance().LifecycleInterface().subscribeOnStateChanged( [&](const std::vector& changes) { - EXPECT_TRUE(changes.size() > 0); + EXPECT_TRUE(!changes.empty()); std::cout << "[Subscription] Lifecycle state changed: " << static_cast(changes[0].newState) - << ", old state: " << static_cast(changes[0].oldState) << std::endl; + << ", old state: " << static_cast(changes[0].oldState) << '\n'; EXPECT_EQ(changes[0].newState, Firebolt::Lifecycle::LifecycleState::PAUSED); EXPECT_EQ(changes[0].oldState, Firebolt::Lifecycle::LifecycleState::INITIALIZING); @@ -91,9 +91,9 @@ TEST_F(LifecycleCTest, subscribeOnState_noValue) auto id = Firebolt::IFireboltAccessor::Instance().LifecycleInterface().subscribeOnStateChanged( [&](const std::vector& changes) { - EXPECT_TRUE(changes.size() > 0); + EXPECT_TRUE(!changes.empty()); std::cout << "[Subscription] Lifecycle state changed: " << static_cast(changes[0].newState) - << ", old state: " << static_cast(changes[0].oldState) << std::endl; + << ", old state: " << static_cast(changes[0].oldState) << '\n'; EXPECT_EQ(changes[0].newState, Firebolt::Lifecycle::LifecycleState::PAUSED); EXPECT_EQ(changes[0].oldState, Firebolt::Lifecycle::LifecycleState::INITIALIZING); @@ -109,7 +109,7 @@ TEST_F(LifecycleCTest, subscribeOnState_noValue) Firebolt::Config config; if (config.legacyRPCv1) { - std::cout << "Commented out as it cannot be tested in CI/CI due to unknown 'id' value" << std::endl; + std::cout << "Commented out as it cannot be tested in CI/CI due to unknown 'id' value" << '\n'; /* nlohmann::json p; p["id"] = 30; diff --git a/test/component/localizationTest.cpp b/test/component/localizationTest.cpp index 3f6a228..8953072 100644 --- a/test/component/localizationTest.cpp +++ b/test/component/localizationTest.cpp @@ -145,3 +145,40 @@ TEST_F(LocalizationCTest, subscribeOnPreferredPresentationLanguageChanged) auto result = Firebolt::IFireboltAccessor::Instance().LocalizationInterface().unsubscribe(id.value_or(0)); ASSERT_TRUE(result) << "error on unsubscribe "; } + +TEST_F(LocalizationCTest, TimeZone) +{ + auto result = Firebolt::IFireboltAccessor::Instance().LocalizationInterface().timeZone(); + ASSERT_TRUE(result) << "error on get"; + + auto expectedValue = jsonEngine.get_value("Localization.timeZone").get(); + EXPECT_EQ(*result, expectedValue); +} + +TEST_F(LocalizationCTest, subscribeOnTimeZoneChanged) +{ + auto id = Firebolt::IFireboltAccessor::Instance().LocalizationInterface().subscribeOnTimeZoneChanged( + [&](const std::string& timeZone) + { + EXPECT_EQ(timeZone, "America/New_York"); + { + std::lock_guard lock(mtx); + eventReceived = true; + } + cv.notify_one(); + }); + + ASSERT_TRUE(id) << "error on subscribe "; + EXPECT_TRUE(id.has_value()) << "error on id"; + + // Trigger the event from the mock server + triggerEvent("Localization.onTimeZoneChanged", R"({"value":"America/New_York"})"); + verifyEventReceived(mtx, cv, eventReceived); + + SetUp(); + triggerEvent("Localization.onTimeZoneChanged", R"({"value":12345})"); + verifyEventNotReceived(mtx, cv, eventReceived); + + auto result = Firebolt::IFireboltAccessor::Instance().LocalizationInterface().unsubscribe(id.value_or(0)); + ASSERT_TRUE(result) << "error on unsubscribe "; +} diff --git a/test/component/metricsTest.cpp b/test/component/metricsTest.cpp index 9e1d6fc..6c1e9b7 100644 --- a/test/component/metricsTest.cpp +++ b/test/component/metricsTest.cpp @@ -200,7 +200,7 @@ TEST_F(MetricsCTest, MediaEnded) TEST_F(MetricsCTest, Event) { auto result = Firebolt::IFireboltAccessor::Instance().MetricsInterface().event("https://com.example.event", - "{\"key\":\"value\"}", + R"({"key":"value"})", Firebolt::AgePolicy::ADULT); ASSERT_TRUE(result) << "MetricsImpl::event() returned an error"; } diff --git a/test/component/networkTest.cpp b/test/component/networkTest.cpp index aedf44c..ae74fb2 100644 --- a/test/component/networkTest.cpp +++ b/test/component/networkTest.cpp @@ -47,7 +47,7 @@ TEST_F(NetworkCTest, SubscribeOnConnectedChanged) auto id = Firebolt::IFireboltAccessor::Instance().NetworkInterface().subscribeOnConnectedChanged( [&](const bool& value) { - std::cout << "[Subscription] Network connected changed" << std::endl; + std::cout << "[Subscription] Network connected changed" << '\n'; EXPECT_EQ(value, true); { std::lock_guard lock(mtx); diff --git a/test/component/presentationTest.cpp b/test/component/presentationTest.cpp index a4bc8f5..d909b61 100644 --- a/test/component/presentationTest.cpp +++ b/test/component/presentationTest.cpp @@ -73,7 +73,7 @@ TEST_F(PresentationCTest, unsubscribeInCallback) Firebolt::IFireboltAccessor::Instance().PresentationInterface().subscribeOnFocusedChanged( [&](const bool& /* focus */) { - std::cout << "In the callback, unsubscribing from the event" << subscriptionId << std::endl; + std::cout << "In the callback, unsubscribing from the event" << subscriptionId << '\n'; auto result = Firebolt::IFireboltAccessor::Instance().PresentationInterface().unsubscribe(subscriptionId); verifyUnsubscribeResult(result); { diff --git a/test/component/speechSynthesisTest.cpp b/test/component/speechSynthesisTest.cpp new file mode 100644 index 0000000..2e98600 --- /dev/null +++ b/test/component/speechSynthesisTest.cpp @@ -0,0 +1,101 @@ +/** + * Copyright 2026 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "firebolt/firebolt.h" +#include "utils.h" +#include + +class SpeechSynthesisCTest : public ::testing::Test +{ +protected: + std::condition_variable cv_; + std::mutex mtx_; + bool eventReceived_ = false; +}; + +TEST_F(SpeechSynthesisCTest, speak) +{ + auto result = Firebolt::IFireboltAccessor::Instance().SpeechSynthesisInterface().speak("component test"); + ASSERT_TRUE(result) << "SpeechSynthesis.speak unavailable"; + EXPECT_GT(*result, 0U); +} + +TEST_F(SpeechSynthesisCTest, voices) +{ + auto result = Firebolt::IFireboltAccessor::Instance().SpeechSynthesisInterface().voices(); + ASSERT_TRUE(result) << "SpeechSynthesis.voices unavailable"; + EXPECT_GE(result->size(), 1U); + EXPECT_FALSE((*result)[0].name.empty()); + EXPECT_FALSE((*result)[0].lang.empty()); +} + +TEST_F(SpeechSynthesisCTest, cancelPauseResume) +{ + constexpr Firebolt::SpeechSynthesis::UtteranceId id = 1; + + auto cancelResult = Firebolt::IFireboltAccessor::Instance().SpeechSynthesisInterface().cancel(id); + ASSERT_TRUE(cancelResult) << "SpeechSynthesis.cancel unavailable"; + + auto pauseResult = Firebolt::IFireboltAccessor::Instance().SpeechSynthesisInterface().pause(id); + ASSERT_TRUE(pauseResult) << "pause failed after successful cancel call"; + + auto resumeResult = Firebolt::IFireboltAccessor::Instance().SpeechSynthesisInterface().resume(id); + ASSERT_TRUE(resumeResult) << "resume failed after successful cancel call"; +} + +TEST_F(SpeechSynthesisCTest, subscribeOnVoicesChanged) +{ + auto id = Firebolt::IFireboltAccessor::Instance().SpeechSynthesisInterface().subscribeOnVoicesChanged( + [&](const std::pmr::vector& voices) + { + ASSERT_EQ(voices.size(), 1U); + EXPECT_EQ(voices[0].name, "Salli"); + EXPECT_EQ(voices[0].lang, "en-US"); + EXPECT_TRUE(voices[0]._default); + { + std::lock_guard lock(mtx_); + eventReceived_ = true; + } + cv_.notify_one(); + }); + + verifyEventSubscription(id); + + triggerEvent("SpeechSynthesis.onVoicesChanged", R"([{"name":"Salli","lang":"en-US","default":true}])"); + verifyEventReceived(mtx_, cv_, eventReceived_); +} + +TEST_F(SpeechSynthesisCTest, subscribeOnUtteranceEvent) +{ + auto id = Firebolt::IFireboltAccessor::Instance().SpeechSynthesisInterface().subscribeOnUtteranceEvent( + [&](const Firebolt::SpeechSynthesis::UtteranceEvent& event) + { + EXPECT_EQ(event.id, static_cast(7)); + EXPECT_EQ(event.event, Firebolt::SpeechSynthesis::UtteranceEventEnum::resumed); + { + std::lock_guard lock(mtx_); + eventReceived_ = true; + } + cv_.notify_one(); + }); + + verifyEventSubscription(id); + + triggerEvent("SpeechSynthesis.onUtteranceEvent", R"({"id":7,"event":"resumed"})"); + verifyEventReceived(mtx_, cv_, eventReceived_); +} diff --git a/test/component/statsTest.cpp b/test/component/statsTest.cpp index 01d8a29..3f9676e 100644 --- a/test/component/statsTest.cpp +++ b/test/component/statsTest.cpp @@ -33,8 +33,8 @@ TEST_F(StatsCTest, MemoryUsage) ASSERT_TRUE(result) << "StatsImpl::memoryUsage() returned an error"; - EXPECT_EQ(result->gpuMemoryLimit, expectedValue.at("gpuMemoryLimitKiB").get()); - EXPECT_EQ(result->gpuMemoryUsed, expectedValue.at("gpuMemoryUsedKiB").get()); - EXPECT_EQ(result->userMemoryLimit, expectedValue.at("userMemoryLimitKiB").get()); - EXPECT_EQ(result->userMemoryUsed, expectedValue.at("userMemoryUsedKiB").get()); + EXPECT_EQ(result->gpuMemoryLimit, expectedValue.at("gpuMemoryLimit").get()); + EXPECT_EQ(result->gpuMemoryUsed, expectedValue.at("gpuMemoryUsed").get()); + EXPECT_EQ(result->userMemoryLimit, expectedValue.at("userMemoryLimit").get()); + EXPECT_EQ(result->userMemoryUsed, expectedValue.at("userMemoryUsed").get()); } diff --git a/test/component/textToSpeechTest.cpp b/test/component/textToSpeechTest.cpp index 1627d7e..4be5478 100644 --- a/test/component/textToSpeechTest.cpp +++ b/test/component/textToSpeechTest.cpp @@ -20,6 +20,7 @@ #include "firebolt/firebolt.h" #include "json_engine.h" #include "utils.h" +#include class TextToSpeechCTest : public ::testing::Test { @@ -49,6 +50,37 @@ TEST_F(TextToSpeechCTest, speak) EXPECT_EQ(speakResult->success, expectedValue["success"].get()); } +TEST_F(TextToSpeechCTest, speak_withAllOptionalArguments) +{ + auto speakResult = + Firebolt::IFireboltAccessor::Instance().TextToSpeechInterface().speak("I am a text waiting for speech.", + std::string("AppA"), std::string("en-US"), + std::string("female-1"), + std::string("80"), std::string("normal"), + std::string("medium")); + ASSERT_TRUE(speakResult) << "Error on speak with all optional arguments"; + + auto expectedValue = jsonEngine.get_value("TextToSpeech.speak"); + EXPECT_EQ(speakResult->speechId, expectedValue["speechid"].get()); + EXPECT_EQ(speakResult->ttsStatus, expectedValue["TTS_Status"].get()); + EXPECT_EQ(speakResult->success, expectedValue["success"].get()); +} + +TEST_F(TextToSpeechCTest, speak_withSelectedOptionalArguments) +{ + auto speakResult = + Firebolt::IFireboltAccessor::Instance().TextToSpeechInterface().speak("I am a text waiting for speech.", + std::nullopt, std::string("en-US"), + std::nullopt, std::nullopt, + std::string("normal"), std::nullopt); + ASSERT_TRUE(speakResult) << "Error on speak with selected optional arguments"; + + auto expectedValue = jsonEngine.get_value("TextToSpeech.speak"); + EXPECT_EQ(speakResult->speechId, expectedValue["speechid"].get()); + EXPECT_EQ(speakResult->ttsStatus, expectedValue["TTS_Status"].get()); + EXPECT_EQ(speakResult->success, expectedValue["success"].get()); +} + TEST_F(TextToSpeechCTest, pause) { int32_t speechId = 1; diff --git a/test/component/videooutputGeneratedTest.cpp b/test/component/videooutputGeneratedTest.cpp new file mode 100644 index 0000000..b2f9958 --- /dev/null +++ b/test/component/videooutputGeneratedTest.cpp @@ -0,0 +1,36 @@ +/** + * Copyright 2026 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "firebolt/videooutput.h" +#include + +TEST(VideooutputGeneratedCTest, InterfaceSurfaceHasresolution) +{ + using Interface = Firebolt::VideoOutput::IVideoOutput; + auto ptr = &Interface::resolution; + (void)ptr; + SUCCEED(); +} + +TEST(VideooutputGeneratedCTest, InterfaceSurfaceHascolorDepth) +{ + using Interface = Firebolt::VideoOutput::IVideoOutput; + auto ptr = &Interface::colorDepth; + (void)ptr; + SUCCEED(); +} diff --git a/test/unit/actionsTest.cpp b/test/unit/actionsTest.cpp index 1b87480..98936f7 100644 --- a/test/unit/actionsTest.cpp +++ b/test/unit/actionsTest.cpp @@ -20,19 +20,27 @@ #include "json_engine.h" #include "mock_helper.h" +using ::testing::Invoke; + class ActionsUTest : public ::testing::Test, protected MockBase { protected: Firebolt::Actions::ActionsImpl actionsImpl_{mockHelper}; }; -TEST_F(ActionsUTest, Start) +TEST_F(ActionsUTest, Intent) { - mock_with_response("Actions.intent", "launch"); + mock_with_response("Actions.intent", + nlohmann::json({{"intent", {{"action", "pre-load"}, {"context", {{"source", "system"}}}}}, + {"intentId", 0U}})); auto result = actionsImpl_.intent(); ASSERT_TRUE(result) << "ActionsImpl::intent() returned an error"; - EXPECT_EQ(*result, "launch"); + EXPECT_EQ(result->intent.action, "pre-load"); + ASSERT_TRUE(result->intent.context); + ASSERT_TRUE(result->intent.context->source); + EXPECT_EQ(*result->intent.context->source, "system"); + EXPECT_EQ(result->intentId, 0U); } TEST_F(ActionsUTest, SubscribeOnIntent) @@ -40,7 +48,7 @@ TEST_F(ActionsUTest, SubscribeOnIntent) nlohmann::json expectedValue = 1; mockSubscribe("Actions.onIntent"); - auto result = actionsImpl_.subscribeOnIntent([&](const std::string& /*value*/) {}); + auto result = actionsImpl_.subscribeOnIntent([&](const Firebolt::Actions::Intent& /*value*/) {}); ASSERT_TRUE(result) << "ActionsImpl::subscribeOnIntent() returned an error"; EXPECT_EQ(*result, expectedValue); @@ -48,3 +56,16 @@ TEST_F(ActionsUTest, SubscribeOnIntent) auto unsubResult = actionsImpl_.unsubscribe(*result); ASSERT_TRUE(unsubResult) << "ActionsImpl::unsubscribe() returned an error"; } + +TEST_F(ActionsUTest, Start) +{ + nlohmann::json expectedParams; + expectedParams["intent"] = {{"action", "pre-load"}, {"context", {{"source", "system"}}}}; + EXPECT_CALL(mockHelper, invoke("Actions.start", expectedParams)) + .WillOnce(Invoke([&](const std::string& /*methodName*/, const nlohmann::json& /*parameters*/) + { return Firebolt::Result{Firebolt::Error::None}; })); + + auto result = + actionsImpl_.start(Firebolt::Actions::IntentData{"pre-load", Firebolt::Actions::IntentContext{{"system"}}}); + ASSERT_TRUE(result) << "ActionsImpl::start() returned an error"; +} diff --git a/test/unit/deviceTest.cpp b/test/unit/deviceTest.cpp index c708b9a..a90a1bc 100644 --- a/test/unit/deviceTest.cpp +++ b/test/unit/deviceTest.cpp @@ -56,6 +56,56 @@ TEST_F(DeviceUTest, DeviceClass) EXPECT_EQ(static_cast(*result), static_cast(Firebolt::Device::JsonData::DeviceClassEnum.at(expectedValue))); } +TEST_F(DeviceUTest, OsName) +{ + mock_with_response("Device.osName", "Linux"); + + auto result = deviceImpl_.osName(); + ASSERT_TRUE(result) << "DeviceImpl::osName() returned an error"; + EXPECT_EQ(*result, "Linux"); +} + +TEST_F(DeviceUTest, SetOsName) +{ + EXPECT_CALL(mockHelper, invoke("Device.setOsName", nlohmann::json("Linux"))) + .WillOnce(Invoke([](const std::string&, const nlohmann::json&) + { return Firebolt::Result{Firebolt::Error::None}; })); + + auto result = deviceImpl_.setOsName("Linux"); + + ASSERT_TRUE(result); +} + +TEST_F(DeviceUTest, OsVersion) +{ + + mock_with_response("Device.osVersion", "5.15.0"); + + auto result = deviceImpl_.osVersion(); + ASSERT_TRUE(result) << "DeviceImpl::osVersion() returned an error"; + EXPECT_EQ(*result, "5.15.0"); +} + +TEST_F(DeviceUTest, SetOsVersion) +{ + EXPECT_CALL(mockHelper, invoke("Device.setOsVersion", nlohmann::json("5.15.0"))) + .WillOnce(Invoke([](const std::string&, const nlohmann::json&) + { return Firebolt::Result{Firebolt::Error::None}; })); + + auto result = deviceImpl_.setOsVersion("5.15.0"); + + ASSERT_TRUE(result); +} + +TEST_F(DeviceUTest, Firmware) +{ + mock_with_response("Device.firmware", "1.0.0-20240101"); + + auto result = deviceImpl_.firmware(); + ASSERT_TRUE(result) << "DeviceImpl::firmware() returned an error"; + EXPECT_EQ(*result, "1.0.0-20240101"); +} + TEST_F(DeviceUTest, DeviceClassBadResponse) { mock_with_response("Device.deviceClass", "abc"); @@ -139,3 +189,34 @@ TEST_F(DeviceUTest, SubscribeOnHdrChanged) deviceImpl_.unsubscribe(*result); } + +TEST_F(DeviceUTest, DolbyAtmosExperienceAvailable) +{ + mock("Device.dolbyAtmosExperienceAvailable"); + auto expectedValue = jsonEngine.get_value("Device.dolbyAtmosExperienceAvailable"); + + auto result = deviceImpl_.dolbyAtmosExperienceAvailable(); + ASSERT_TRUE(result) << "DeviceImpl::dolbyAtmosExperienceAvailable() returned an error"; + + EXPECT_EQ(*result, expectedValue.get()); +} + +TEST_F(DeviceUTest, DolbyAtmosExperienceAvailableBadResponse) +{ + mock_with_response("Device.dolbyAtmosExperienceAvailable", "invalid_response"); + ASSERT_FALSE(deviceImpl_.dolbyAtmosExperienceAvailable()) + << "DeviceImpl::dolbyAtmosExperienceAvailable() did not return an error"; +} + +TEST_F(DeviceUTest, SubscribeOnDolbyAtmosExperienceAvailableChanged) +{ + nlohmann::json expectedValue = 1; + mockSubscribe("Device.onDolbyAtmosExperienceAvailableChanged"); + + auto result = deviceImpl_.subscribeOnDolbyAtmosExperienceAvailableChanged([&](const bool& /*value*/) {}); + + ASSERT_TRUE(result) << "DeviceImpl::subscribeOnDolbyAtmosExperienceAvailableChanged() returned an error"; + EXPECT_EQ(*result, expectedValue); + + deviceImpl_.unsubscribe(*result); +} diff --git a/test/unit/discoveryTest.cpp b/test/unit/discoveryTest.cpp index edba6d9..aee6363 100644 --- a/test/unit/discoveryTest.cpp +++ b/test/unit/discoveryTest.cpp @@ -38,7 +38,7 @@ TEST_F(DiscoveryUTest, watched) { mock("Discovery.watched"); std::string entityId = "content123"; - std::optional progress = 0.75f; + std::optional progress = 0.75F; std::optional completed = true; std::optional watchedOn = "2024-06-01T12:00:00Z"; std::optional agePolicy = Firebolt::AgePolicy::ADULT; @@ -53,7 +53,7 @@ TEST_F(DiscoveryUTest, watched_payload) { nlohmann::json expected; expected["entityId"] = "content123"; - expected["progress"] = 0.75f; + expected["progress"] = 0.75F; expected["completed"] = true; expected["watchedOn"] = "2024-06-01T12:00:00Z"; expected["agePolicy"] = "app:adult"; @@ -67,7 +67,7 @@ TEST_F(DiscoveryUTest, watched_payload) return Firebolt::Result{nlohmann::json(res)}; })); std::string entityId = "content123"; - std::optional progress = 0.75f; + std::optional progress = 0.75F; std::optional completed = true; std::optional watchedOn = "2024-06-01T12:00:00Z"; std::optional agePolicy = Firebolt::AgePolicy::ADULT; @@ -78,39 +78,37 @@ TEST_F(DiscoveryUTest, watched_payload) TEST_F(DiscoveryUTest, watchedV2) { - mock("Discovery.watchedV2"); + mockInvoke("Discovery.watched"); std::string entityId = "content123"; - std::optional progress = 0.75f; + std::optional progress = 0.75F; std::optional completed = true; std::optional watchedOn = "2024-06-01T12:00:00Z"; std::optional agePolicy = Firebolt::AgePolicy::ADULT; auto result = discoveryImpl_.watchedV2(entityId, progress, completed, watchedOn, agePolicy); ASSERT_TRUE(result) << "Error on watchedV2"; - EXPECT_TRUE(*result); } TEST_F(DiscoveryUTest, watchedV2_payload) { nlohmann::json expected; expected["entityId"] = "content123"; - expected["progress"] = 0.75f; + expected["progress"] = 0.75F; expected["completed"] = true; expected["watchedOn"] = "2024-06-01T12:00:00Z"; expected["agePolicy"] = "app:adult"; - EXPECT_CALL(mockHelper, getJson("Discovery.watchedV2", _)) + EXPECT_CALL(mockHelper, invoke("Discovery.watched", _)) .WillOnce(Invoke( [&](const std::string& /* methodName */, const nlohmann::json& parameters) { EXPECT_EQ(parameters, expected) << "Parameters do not match expected payload: " << expected.dump() << " but got: " << parameters.dump(); - return Firebolt::Result{nlohmann::json(true)}; + return Firebolt::Result{Firebolt::Error::None}; })); std::string entityId = "content123"; - std::optional progress = 0.75f; + std::optional progress = 0.75F; std::optional completed = true; std::optional watchedOn = "2024-06-01T12:00:00Z"; std::optional agePolicy = Firebolt::AgePolicy::ADULT; auto result = discoveryImpl_.watchedV2(entityId, progress, completed, watchedOn, agePolicy); ASSERT_TRUE(result) << "Error on watchedV2"; - EXPECT_TRUE(*result); } diff --git a/test/unit/localizationTest.cpp b/test/unit/localizationTest.cpp index fb1eaeb..29a5f69 100644 --- a/test/unit/localizationTest.cpp +++ b/test/unit/localizationTest.cpp @@ -113,3 +113,31 @@ TEST_F(LocalizationUTest, subscribeOnPresentationLanguageChanged) auto result = localizationImpl_.unsubscribe(id.value_or(0)); ASSERT_TRUE(result) << "error on unsubscribe "; } + +TEST_F(LocalizationUTest, TimeZone) +{ + auto expectedValue = jsonEngine.get_value("Localization.timeZone").get(); + mock("Localization.timeZone"); + + auto result = localizationImpl_.timeZone(); + ASSERT_TRUE(result) << "error on get"; + + EXPECT_EQ(*result, expectedValue); +} + +TEST_F(LocalizationUTest, TimeZoneBadResponse) +{ + mock_with_response("Localization.timeZone", 12345); + ASSERT_FALSE(localizationImpl_.timeZone()) << "LocalizationImpl::timeZone() did not return an error"; +} + +TEST_F(LocalizationUTest, subscribeOnTimeZoneChanged) +{ + mockSubscribe("Localization.onTimeZoneChanged"); + + auto id = localizationImpl_.subscribeOnTimeZoneChanged([](auto) {}); + ASSERT_TRUE(id) << "error on subscribe "; + EXPECT_TRUE(id.has_value()) << "error on id"; + auto result = localizationImpl_.unsubscribe(id.value_or(0)); + ASSERT_TRUE(result) << "error on unsubscribe "; +} diff --git a/test/unit/metricsTest.cpp b/test/unit/metricsTest.cpp index a7dbcab..627ec7e 100644 --- a/test/unit/metricsTest.cpp +++ b/test/unit/metricsTest.cpp @@ -173,7 +173,7 @@ TEST_F(MetricsUTest, MediaEnded) TEST_F(MetricsUTest, Event) { mockInvoke("Metrics.event"); - auto result = metricsImpl_.event("https://com.example.schema", "{\"key\":\"value\"}", Firebolt::AgePolicy::ADULT); + auto result = metricsImpl_.event("https://com.example.schema", R"({"key":"value"})", Firebolt::AgePolicy::ADULT); EXPECT_TRUE(result); } diff --git a/test/unit/speechSynthesisTest.cpp b/test/unit/speechSynthesisTest.cpp new file mode 100644 index 0000000..b33d650 --- /dev/null +++ b/test/unit/speechSynthesisTest.cpp @@ -0,0 +1,253 @@ +/** + * Copyright 2026 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "json_types/speechsynthesis.h" +#include "mock_helper.h" +#include "speechsynthesis_impl.h" + +class SpeechSynthesisUTest : public ::testing::Test, protected MockBase +{ +protected: + Firebolt::SpeechSynthesis::SpeechSynthesisImpl speechSynthesisImpl_{mockHelper}; +}; + +TEST_F(SpeechSynthesisUTest, Constructs) +{ + SUCCEED(); +} + +TEST_F(SpeechSynthesisUTest, speak) +{ + EXPECT_CALL(mockHelper, getJson("SpeechSynthesis.speak", _)) + .WillOnce(Invoke([&](const std::string& /*methodName*/, const nlohmann::json& /*parameters*/) + { return Firebolt::Result{42}; })); + + auto result = speechSynthesisImpl_.speak("Hello from speech synthesis"); + + ASSERT_TRUE(result); + EXPECT_EQ(*result, 42U); +} + +TEST_F(SpeechSynthesisUTest, speak_payloadIncludesOnlyProvidedOptionalFields) +{ + EXPECT_CALL(mockHelper, getJson("SpeechSynthesis.speak", _)) + .WillOnce(Invoke( + [&](const std::string& /*methodName*/, const nlohmann::json& parameters) + { + nlohmann::json expected; + expected["text"] = "payload"; + expected["language"] = "en-US"; + expected["pitch"] = "medium"; + EXPECT_EQ(parameters, expected); + return Firebolt::Result{7}; + })); + + auto result = speechSynthesisImpl_.speak("payload", std::nullopt, std::string("en-US"), std::nullopt, std::nullopt, + std::nullopt, std::string("medium")); + + ASSERT_TRUE(result); + EXPECT_EQ(*result, 7U); +} + +TEST_F(SpeechSynthesisUTest, speak_invalidResponse) +{ + mock_with_response("SpeechSynthesis.speak", "not-a-number"); + + auto result = speechSynthesisImpl_.speak("Hello from speech synthesis"); + + ASSERT_FALSE(result); +} + +TEST_F(SpeechSynthesisUTest, voices) +{ + nlohmann::json response = nlohmann::json::array(); + response.push_back({{"name", "Salli"}, {"lang", "en-US"}, {"default", true}}); + response.push_back({{"name", "Arthur"}, {"lang", "en-GB"}, {"default", false}}); + mock_with_response("SpeechSynthesis.voices", response); + + auto result = speechSynthesisImpl_.voices(); + + ASSERT_TRUE(result); + ASSERT_EQ(result->size(), 2U); + EXPECT_EQ((*result)[0].name, "Salli"); + EXPECT_EQ((*result)[0].lang, "en-US"); + EXPECT_TRUE((*result)[0]._default); + EXPECT_EQ((*result)[1].name, "Arthur"); + EXPECT_EQ((*result)[1].lang, "en-GB"); + EXPECT_FALSE((*result)[1]._default); +} + +TEST_F(SpeechSynthesisUTest, voices_payloadHasNoParameters) +{ + EXPECT_CALL(mockHelper, getJson("SpeechSynthesis.voices", _)) + .WillOnce(Invoke( + [&](const std::string& /*methodName*/, const nlohmann::json& parameters) + { + EXPECT_TRUE(parameters.is_object()); + EXPECT_TRUE(parameters.empty()); + nlohmann::json response = nlohmann::json::array(); + response.push_back({{"name", "Salli"}, {"lang", "en-US"}, {"default", true}}); + return Firebolt::Result{response}; + })); + + auto result = speechSynthesisImpl_.voices(); + + ASSERT_TRUE(result); + ASSERT_EQ(result->size(), 1U); +} + +TEST_F(SpeechSynthesisUTest, voices_invalidResponse) +{ + nlohmann::json badResponse = {{"name", "not-an-array"}}; + mock_with_response("SpeechSynthesis.voices", badResponse); + + auto result = speechSynthesisImpl_.voices(); + + ASSERT_FALSE(result); +} + +TEST_F(SpeechSynthesisUTest, subscribeOnVoicesChanged) +{ + EXPECT_CALL(mockHelper, subscribe(_, "SpeechSynthesis.onVoicesChanged", _, _)) + .WillOnce(::testing::Return(Firebolt::Result{1})); + + auto result = speechSynthesisImpl_.subscribeOnVoicesChanged( + [](const std::pmr::vector& /*voices*/) {}); + ASSERT_TRUE(result) << "error on subscribe "; + EXPECT_TRUE(result.has_value()) << "error on id"; +} + +TEST_F(SpeechSynthesisUTest, subscribeOnVoicesChanged_subscribeError) +{ + EXPECT_CALL(mockHelper, subscribe(_, "SpeechSynthesis.onVoicesChanged", _, _)) + .WillOnce(::testing::Return(Firebolt::Result{Firebolt::Error::General})); + + auto result = speechSynthesisImpl_.subscribeOnVoicesChanged( + [](const std::pmr::vector& /*voices*/) {}); + ASSERT_FALSE(result); + EXPECT_EQ(result.error(), Firebolt::Error::General); +} + +TEST_F(SpeechSynthesisUTest, cancel) +{ + nlohmann::json expectedParams; + expectedParams["id"] = static_cast(123); + + EXPECT_CALL(mockHelper, invoke("SpeechSynthesis.cancel", expectedParams)) + .WillOnce(::testing::Return(Firebolt::Result{Firebolt::Error::None})); + + auto result = speechSynthesisImpl_.cancel(static_cast(123)); + ASSERT_TRUE(result); +} + +TEST_F(SpeechSynthesisUTest, cancel_invokeError) +{ + EXPECT_CALL(mockHelper, invoke("SpeechSynthesis.cancel", _)) + .WillOnce(::testing::Return(Firebolt::Result{Firebolt::Error::General})); + + auto result = speechSynthesisImpl_.cancel(static_cast(123)); + ASSERT_FALSE(result); + EXPECT_EQ(result.error(), Firebolt::Error::General); +} + +TEST_F(SpeechSynthesisUTest, pause) +{ + nlohmann::json expectedParams; + expectedParams["id"] = static_cast(123); + + EXPECT_CALL(mockHelper, invoke("SpeechSynthesis.pause", expectedParams)) + .WillOnce(::testing::Return(Firebolt::Result{Firebolt::Error::None})); + + auto result = speechSynthesisImpl_.pause(static_cast(123)); + ASSERT_TRUE(result); +} + +TEST_F(SpeechSynthesisUTest, pause_invokeError) +{ + EXPECT_CALL(mockHelper, invoke("SpeechSynthesis.pause", _)) + .WillOnce(::testing::Return(Firebolt::Result{Firebolt::Error::General})); + + auto result = speechSynthesisImpl_.pause(static_cast(123)); + ASSERT_FALSE(result); + EXPECT_EQ(result.error(), Firebolt::Error::General); +} + +TEST_F(SpeechSynthesisUTest, resume) +{ + nlohmann::json expectedParams; + expectedParams["id"] = static_cast(123); + + EXPECT_CALL(mockHelper, invoke("SpeechSynthesis.resume", expectedParams)) + .WillOnce(::testing::Return(Firebolt::Result{Firebolt::Error::None})); + + auto result = speechSynthesisImpl_.resume(static_cast(123)); + ASSERT_TRUE(result); +} +// Removed duplicate pause test + +TEST_F(SpeechSynthesisUTest, resume_invokeError) +{ + EXPECT_CALL(mockHelper, invoke("SpeechSynthesis.resume", _)) + .WillOnce(::testing::Return(Firebolt::Result{Firebolt::Error::General})); + + auto result = speechSynthesisImpl_.resume(static_cast(123)); + ASSERT_FALSE(result); + EXPECT_EQ(result.error(), Firebolt::Error::General); +} + +TEST_F(SpeechSynthesisUTest, subscribeOnUtteranceEvent) +{ + EXPECT_CALL(mockHelper, subscribe(_, "SpeechSynthesis.onUtteranceEvent", _, _)) + .WillOnce(::testing::Return(Firebolt::Result{1})); + + auto result = speechSynthesisImpl_.subscribeOnUtteranceEvent( + [](const Firebolt::SpeechSynthesis::UtteranceEvent& /*event*/) {}); + ASSERT_TRUE(result) << "error on subscribe "; + EXPECT_TRUE(result.has_value()) << "error on id"; +} + +TEST_F(SpeechSynthesisUTest, subscribeOnUtteranceEvent_subscribeError) +{ + EXPECT_CALL(mockHelper, subscribe(_, "SpeechSynthesis.onUtteranceEvent", _, _)) + .WillOnce(::testing::Return(Firebolt::Result{Firebolt::Error::General})); + + auto result = speechSynthesisImpl_.subscribeOnUtteranceEvent( + [](const Firebolt::SpeechSynthesis::UtteranceEvent& /*event*/) {}); + ASSERT_FALSE(result); + EXPECT_EQ(result.error(), Firebolt::Error::General); +} + +TEST_F(SpeechSynthesisUTest, utteranceEventResponse_parsesValidPayload) +{ + Firebolt::SpeechSynthesis::JsonData::UtteranceEventResponse response; + nlohmann::json payload = {{"id", static_cast(77)}, {"event", "resumed"}}; + + response.fromJson(payload); + auto value = response.value(); + + EXPECT_EQ(value.id, static_cast(77)); + EXPECT_EQ(value.event, Firebolt::SpeechSynthesis::UtteranceEventEnum::resumed); +} + +TEST_F(SpeechSynthesisUTest, utteranceEventResponse_rejectsUnknownEvent) +{ + Firebolt::SpeechSynthesis::JsonData::UtteranceEventResponse response; + nlohmann::json payload = {{"id", static_cast(77)}, {"event", "not-valid"}}; + + EXPECT_THROW(response.fromJson(payload), std::invalid_argument); +} \ No newline at end of file diff --git a/test/unit/statsTest.cpp b/test/unit/statsTest.cpp index fb614e7..355695f 100644 --- a/test/unit/statsTest.cpp +++ b/test/unit/statsTest.cpp @@ -35,10 +35,10 @@ TEST_F(StatsUTest, MemoryUsage) ASSERT_TRUE(result) << "StatsImpl::memoryUsage() returned an error"; - EXPECT_EQ(result->userMemoryUsed, expectedValue.at("userMemoryUsedKiB").get()); - EXPECT_EQ(result->userMemoryLimit, expectedValue.at("userMemoryLimitKiB").get()); - EXPECT_EQ(result->gpuMemoryUsed, expectedValue.at("gpuMemoryUsedKiB").get()); - EXPECT_EQ(result->gpuMemoryLimit, expectedValue.at("gpuMemoryLimitKiB").get()); + EXPECT_EQ(result->userMemoryUsed, expectedValue.at("userMemoryUsed").get()); + EXPECT_EQ(result->userMemoryLimit, expectedValue.at("userMemoryLimit").get()); + EXPECT_EQ(result->gpuMemoryUsed, expectedValue.at("gpuMemoryUsed").get()); + EXPECT_EQ(result->gpuMemoryLimit, expectedValue.at("gpuMemoryLimit").get()); } TEST_F(StatsUTest, MemoryUsageBadResponse) diff --git a/test/unit/textToSpeechTest.cpp b/test/unit/textToSpeechTest.cpp index 18c0633..1802636 100644 --- a/test/unit/textToSpeechTest.cpp +++ b/test/unit/textToSpeechTest.cpp @@ -62,6 +62,137 @@ TEST_F(TextToSpeechUTest, speak) EXPECT_EQ(speak->success, expectedValue["success"].get()); } +TEST_F(TextToSpeechUTest, speak_payloadDefaultsToTextOnly) +{ + EXPECT_CALL(mockHelper, getJson("TextToSpeech.speak", _)) + .WillOnce(Invoke( + [&](const std::string& /*methodName*/, const nlohmann::json& parameters) + { + nlohmann::json expected = {{"text", "I am a text waiting for speech."}}; + EXPECT_EQ(parameters, expected) << "Parameters do not match expected payload: " << expected.dump() + << " but got: " << parameters.dump(); + return Firebolt::Result{jsonEngine.get_value("TextToSpeech.speak")}; + })); + + auto result = ttsImpl.speak("I am a text waiting for speech."); + ASSERT_TRUE(result); +} + +TEST_F(TextToSpeechUTest, speak_payloadIncludesAllOptionalFieldsWhenProvided) +{ + EXPECT_CALL(mockHelper, getJson("TextToSpeech.speak", _)) + .WillOnce(Invoke( + [&](const std::string& /*methodName*/, const nlohmann::json& parameters) + { + nlohmann::json expected; + expected["text"] = "I am a text waiting for speech."; + expected["callSign"] = "AppA"; + expected["language"] = "en-US"; + expected["voice"] = "female-1"; + expected["volume"] = "80"; + expected["rate"] = "normal"; + expected["pitch"] = "medium"; + EXPECT_EQ(parameters, expected) << "Parameters do not match expected payload: " << expected.dump() + << " but got: " << parameters.dump(); + return Firebolt::Result{jsonEngine.get_value("TextToSpeech.speak")}; + })); + + auto result = ttsImpl.speak("I am a text waiting for speech.", std::string("AppA"), std::string("en-US"), + std::string("female-1"), std::string("80"), std::string("normal"), std::string("medium")); + ASSERT_TRUE(result); +} + +TEST_F(TextToSpeechUTest, speak_payloadIncludesOnlyProvidedOptionalFields) +{ + EXPECT_CALL(mockHelper, getJson("TextToSpeech.speak", _)) + .WillOnce(Invoke( + [&](const std::string& /*methodName*/, const nlohmann::json& parameters) + { + nlohmann::json expected; + expected["text"] = "I am a text waiting for speech."; + expected["language"] = "en-US"; + expected["rate"] = "normal"; + EXPECT_EQ(parameters, expected) << "Parameters do not match expected payload: " << expected.dump() + << " but got: " << parameters.dump(); + return Firebolt::Result{jsonEngine.get_value("TextToSpeech.speak")}; + })); + + auto result = ttsImpl.speak("I am a text waiting for speech.", std::nullopt, std::string("en-US"), std::nullopt, + std::nullopt, std::string("normal"), std::nullopt); + ASSERT_TRUE(result); +} + +TEST_F(TextToSpeechUTest, speak_payloadOmitsUnsetOptionalKeys) +{ + EXPECT_CALL(mockHelper, getJson("TextToSpeech.speak", _)) + .WillOnce(Invoke( + [&](const std::string& /*methodName*/, const nlohmann::json& parameters) + { + EXPECT_EQ(parameters.size(), 1U); + EXPECT_TRUE(parameters.contains("text")); + EXPECT_FALSE(parameters.contains("callSign")); + EXPECT_FALSE(parameters.contains("language")); + EXPECT_FALSE(parameters.contains("voice")); + EXPECT_FALSE(parameters.contains("volume")); + EXPECT_FALSE(parameters.contains("rate")); + EXPECT_FALSE(parameters.contains("pitch")); + return Firebolt::Result{jsonEngine.get_value("TextToSpeech.speak")}; + })); + + auto result = ttsImpl.speak("I am a text waiting for speech."); + ASSERT_TRUE(result); +} + +TEST_F(TextToSpeechUTest, speak_payloadPreservesEmptyStringOptionalValues) +{ + EXPECT_CALL(mockHelper, getJson("TextToSpeech.speak", _)) + .WillOnce(Invoke( + [&](const std::string& /*methodName*/, const nlohmann::json& parameters) + { + EXPECT_TRUE(parameters.contains("text")); + EXPECT_TRUE(parameters.contains("callSign")); + EXPECT_TRUE(parameters.contains("rate")); + EXPECT_EQ(parameters["callSign"], ""); + EXPECT_EQ(parameters["rate"], ""); + EXPECT_FALSE(parameters.contains("language")); + EXPECT_FALSE(parameters.contains("voice")); + EXPECT_FALSE(parameters.contains("volume")); + EXPECT_FALSE(parameters.contains("pitch")); + return Firebolt::Result{jsonEngine.get_value("TextToSpeech.speak")}; + })); + + auto result = ttsImpl.speak("I am a text waiting for speech.", std::string(""), std::nullopt, std::nullopt, + std::nullopt, std::string(""), std::nullopt); + ASSERT_TRUE(result); +} + +TEST_F(TextToSpeechUTest, speak_payloadUsesCallSignKeyNotLegacyCallsign) +{ + EXPECT_CALL(mockHelper, getJson("TextToSpeech.speak", _)) + .WillOnce(Invoke( + [&](const std::string& /*methodName*/, const nlohmann::json& parameters) + { + EXPECT_TRUE(parameters.contains("callSign")); + EXPECT_FALSE(parameters.contains("callsign")); + EXPECT_EQ(parameters["callSign"], "AppA"); + return Firebolt::Result{jsonEngine.get_value("TextToSpeech.speak")}; + })); + + auto result = ttsImpl.speak("I am a text waiting for speech.", std::string("AppA")); + ASSERT_TRUE(result); +} + +TEST_F(TextToSpeechUTest, speak_propagatesHelperError) +{ + EXPECT_CALL(mockHelper, getJson("TextToSpeech.speak", _)) + .WillOnce(Invoke([&](const std::string& /*methodName*/, const nlohmann::json& /*parameters*/) + { return Firebolt::Result{Firebolt::Error::General}; })); + + auto result = ttsImpl.speak("I am a text waiting for speech.", std::string("AppA")); + ASSERT_FALSE(result); + EXPECT_EQ(result.error(), Firebolt::Error::General); +} + TEST_F(TextToSpeechUTest, pause) { mock("TextToSpeech.pause"); diff --git a/test/unit/videooutputGeneratedTest.cpp b/test/unit/videooutputGeneratedTest.cpp new file mode 100644 index 0000000..736ac6c --- /dev/null +++ b/test/unit/videooutputGeneratedTest.cpp @@ -0,0 +1,443 @@ +/** + * Copyright 2026 Comcast Cable Communications Management, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "mock_helper.h" +#include "videooutput_impl.h" +#include +#include + +class VideooutputGeneratedUTest : public ::testing::Test +{ +protected: + static bool areGetterParamsEmpty(const nlohmann::json& params) + { + return params.is_null() || (params.is_object() && params.empty()); + } + + void expectGetterResponse(const std::string& methodName, const nlohmann::json& response) + { + EXPECT_CALL(mockHelper, getJson(methodName, ::testing::_)) + .WillOnce(::testing::Invoke( + [methodName, response](const std::string& /*method*/, const nlohmann::json& params) + { + EXPECT_TRUE(areGetterParamsEmpty(params)) << methodName << " getter should not send request params"; + return Firebolt::Result{response}; + })); + } + + void expectGetterTransportError(const std::string& methodName, Firebolt::Error error = Firebolt::Error::General) + { + EXPECT_CALL(mockHelper, getJson(methodName, ::testing::_)) + .WillOnce(::testing::Invoke( + [methodName, error](const std::string& /*method*/, const nlohmann::json& params) + { + EXPECT_TRUE(areGetterParamsEmpty(params)) << methodName << " getter should not send request params"; + return Firebolt::Result{error}; + })); + } + + ::testing::NiceMock mockHelper; + Firebolt::VideoOutput::VideoOutputImpl impl{mockHelper}; +}; + +TEST_F(VideooutputGeneratedUTest, Constructs) +{ + SUCCEED(); +} + +TEST_F(VideooutputGeneratedUTest, UnsubscribeForwardsToHelper) +{ + EXPECT_CALL(mockHelper, unsubscribe(7)).WillOnce(::testing::Return(Firebolt::Result{Firebolt::Error::None})); + + auto result = impl.unsubscribe(7); + ASSERT_TRUE(result) << "unsubscribe should return success when helper succeeds"; +} + +TEST_F(VideooutputGeneratedUTest, UnsubscribeForwardsHelperErrors) +{ + EXPECT_CALL(mockHelper, unsubscribe(42)).WillOnce(::testing::Return(Firebolt::Result{Firebolt::Error::General})); + + auto result = impl.unsubscribe(42); + ASSERT_FALSE(result); + EXPECT_EQ(result.error(), Firebolt::Error::General); +} + +TEST_F(VideooutputGeneratedUTest, UnsubscribeAllForwardsToHelper) +{ + EXPECT_CALL(mockHelper, unsubscribeAll(&impl)).Times(2); + + impl.unsubscribeAll(); +} + +TEST_F(VideooutputGeneratedUTest, ResolutionReturnsParsedValue) +{ + expectGetterResponse("VideoOutput.resolution", nlohmann::json{{"height", 1080}, {"width", 1920}}); + + auto result = impl.resolution(); + ASSERT_TRUE(result); + EXPECT_EQ(result->height, 1080U); + EXPECT_EQ(result->width, 1920U); +} + +TEST_F(VideooutputGeneratedUTest, ResolutionForwardsTransportErrors) +{ + expectGetterTransportError("VideoOutput.resolution"); + + auto result = impl.resolution(); + ASSERT_FALSE(result); + EXPECT_EQ(result.error(), Firebolt::Error::General); +} + +TEST_F(VideooutputGeneratedUTest, ResolutionReturnsInvalidParamsWhenPayloadIsMalformed) +{ + expectGetterResponse("VideoOutput.resolution", nlohmann::json{{"width", 1920}}); + + auto result = impl.resolution(); + ASSERT_FALSE(result); + EXPECT_EQ(result.error(), Firebolt::Error::InvalidParams); +} + +TEST_F(VideooutputGeneratedUTest, HdcpReturnsParsedValue) +{ + expectGetterResponse("VideoOutput.hdcp", nlohmann::json(static_cast(Firebolt::VideoOutput::HdcpState::Hdcp22))); + + auto result = impl.hdcp(); + ASSERT_TRUE(result); + EXPECT_EQ(*result, Firebolt::VideoOutput::HdcpState::Hdcp22); +} + +TEST_F(VideooutputGeneratedUTest, HdcpForwardsTransportErrors) +{ + expectGetterTransportError("VideoOutput.hdcp"); + + auto result = impl.hdcp(); + ASSERT_FALSE(result); + EXPECT_EQ(result.error(), Firebolt::Error::General); +} + +TEST_F(VideooutputGeneratedUTest, CecStateReturnsParsedValue) +{ + expectGetterResponse("VideoOutput.cecState", + nlohmann::json(static_cast(Firebolt::VideoOutput::CecStateValue::Inactive))); + + auto result = impl.cecState(); + ASSERT_TRUE(result); + EXPECT_EQ(*result, Firebolt::VideoOutput::CecStateValue::Inactive); +} + +TEST_F(VideooutputGeneratedUTest, CecStateForwardsTransportErrors) +{ + expectGetterTransportError("VideoOutput.cecState"); + + auto result = impl.cecState(); + ASSERT_FALSE(result); + EXPECT_EQ(result.error(), Firebolt::Error::General); +} + +TEST_F(VideooutputGeneratedUTest, RefreshRateReturnsParsedValue) +{ + expectGetterResponse("VideoOutput.refreshRate", + nlohmann::json(static_cast(Firebolt::VideoOutput::RefreshRateValue::R5994))); + + auto result = impl.refreshRate(); + ASSERT_TRUE(result); + EXPECT_EQ(*result, Firebolt::VideoOutput::RefreshRateValue::R5994); +} + +TEST_F(VideooutputGeneratedUTest, RefreshRateForwardsTransportErrors) +{ + expectGetterTransportError("VideoOutput.refreshRate"); + + auto result = impl.refreshRate(); + ASSERT_FALSE(result); + EXPECT_EQ(result.error(), Firebolt::Error::General); +} + +TEST_F(VideooutputGeneratedUTest, ColorDepthReturnsParsedValue) +{ + expectGetterResponse("VideoOutput.colorDepth", + nlohmann::json(static_cast(Firebolt::VideoOutput::ColorDepthValue::D12))); + + auto result = impl.colorDepth(); + ASSERT_TRUE(result); + EXPECT_EQ(*result, Firebolt::VideoOutput::ColorDepthValue::D12); +} + +TEST_F(VideooutputGeneratedUTest, ColorDepthForwardsTransportErrors) +{ + expectGetterTransportError("VideoOutput.colorDepth"); + + auto result = impl.colorDepth(); + ASSERT_FALSE(result); + EXPECT_EQ(result.error(), Firebolt::Error::General); +} + +TEST_F(VideooutputGeneratedUTest, ColorFormatReturnsParsedValue) +{ + expectGetterResponse("VideoOutput.colorFormat", + nlohmann::json(static_cast(Firebolt::VideoOutput::ColorFormatValue::Ycbcr444))); + + auto result = impl.colorFormat(); + ASSERT_TRUE(result); + EXPECT_EQ(*result, Firebolt::VideoOutput::ColorFormatValue::Ycbcr444); +} + +TEST_F(VideooutputGeneratedUTest, ColorFormatForwardsTransportErrors) +{ + expectGetterTransportError("VideoOutput.colorFormat"); + + auto result = impl.colorFormat(); + ASSERT_FALSE(result); + EXPECT_EQ(result.error(), Firebolt::Error::General); +} + +TEST_F(VideooutputGeneratedUTest, ColorimetryReturnsParsedValue) +{ + expectGetterResponse("VideoOutput.colorimetry", + nlohmann::json(static_cast(Firebolt::VideoOutput::OutputColorimetry::Bt2020rgb))); + + auto result = impl.colorimetry(); + ASSERT_TRUE(result); + EXPECT_EQ(*result, Firebolt::VideoOutput::OutputColorimetry::Bt2020rgb); +} + +TEST_F(VideooutputGeneratedUTest, ColorimetryForwardsTransportErrors) +{ + expectGetterTransportError("VideoOutput.colorimetry"); + + auto result = impl.colorimetry(); + ASSERT_FALSE(result); + EXPECT_EQ(result.error(), Firebolt::Error::General); +} + +TEST_F(VideooutputGeneratedUTest, DynamicRangeReturnsParsedValue) +{ + expectGetterResponse("VideoOutput.dynamicRange", + nlohmann::json(static_cast(Firebolt::VideoOutput::DynamicRangeValue::Hdr10plus))); + + auto result = impl.dynamicRange(); + ASSERT_TRUE(result); + EXPECT_EQ(*result, Firebolt::VideoOutput::DynamicRangeValue::Hdr10plus); +} + +TEST_F(VideooutputGeneratedUTest, DynamicRangeForwardsTransportErrors) +{ + expectGetterTransportError("VideoOutput.dynamicRange"); + + auto result = impl.dynamicRange(); + ASSERT_FALSE(result); + EXPECT_EQ(result.error(), Firebolt::Error::General); +} + +TEST_F(VideooutputGeneratedUTest, QuantizationRangeReturnsParsedValue) +{ + expectGetterResponse("VideoOutput.quantizationRange", + nlohmann::json(static_cast(Firebolt::VideoOutput::QuantizationRangeValue::Limited))); + + auto result = impl.quantizationRange(); + ASSERT_TRUE(result); + EXPECT_EQ(*result, Firebolt::VideoOutput::QuantizationRangeValue::Limited); +} + +TEST_F(VideooutputGeneratedUTest, QuantizationRangeForwardsTransportErrors) +{ + expectGetterTransportError("VideoOutput.quantizationRange"); + + auto result = impl.quantizationRange(); + ASSERT_FALSE(result); + EXPECT_EQ(result.error(), Firebolt::Error::General); +} + +TEST_F(VideooutputGeneratedUTest, SubscribeOnResolutionChangedForwardsAndDispatchesParsedPayload) +{ + bool notified = false; + Firebolt::VideoOutput::VideoOutputResolution received{}; + + EXPECT_CALL(mockHelper, subscribe(&impl, "VideoOutput.onResolutionChanged", ::testing::_, ::testing::_)) + .WillOnce(::testing::Invoke( + [&](void* owner, const std::string& eventName, std::any&& notification, + void (*callback)(void*, const nlohmann::json&)) + { + Firebolt::Helpers::SubscriptionData data{owner, eventName, std::move(notification)}; + callback(&data, nlohmann::json{{"height", 2160}, {"width", 3840}}); + return Firebolt::Result{99}; + })); + + auto result = impl.subscribeOnResolutionChanged( + [&](const Firebolt::VideoOutput::VideoOutputResolution& value) + { + notified = true; + received = value; + }); + + ASSERT_TRUE(result); + EXPECT_EQ(*result, 99U); + EXPECT_TRUE(notified); + EXPECT_EQ(received.height, 2160U); + EXPECT_EQ(received.width, 3840U); +} + +TEST_F(VideooutputGeneratedUTest, SubscribeOnResolutionChangedSwallowsMalformedEventPayload) +{ + bool notified = false; + + EXPECT_CALL(mockHelper, subscribe(&impl, "VideoOutput.onResolutionChanged", ::testing::_, ::testing::_)) + .WillOnce(::testing::Invoke( + [&](void* owner, const std::string& eventName, std::any&& notification, + void (*callback)(void*, const nlohmann::json&)) + { + Firebolt::Helpers::SubscriptionData data{owner, eventName, std::move(notification)}; + callback(&data, nlohmann::json{{"width", 3840}}); + return Firebolt::Result{5}; + })); + + auto result = impl.subscribeOnResolutionChanged([&](const Firebolt::VideoOutput::VideoOutputResolution& /*value*/) + { notified = true; }); + + ASSERT_TRUE(result); + EXPECT_EQ(*result, 5U); + EXPECT_FALSE(notified); +} + +TEST_F(VideooutputGeneratedUTest, SubscribeOnResolutionChangedForwardsSubscribeErrors) +{ + EXPECT_CALL(mockHelper, subscribe(&impl, "VideoOutput.onResolutionChanged", ::testing::_, ::testing::_)) + .WillOnce(::testing::Return(Firebolt::Result{Firebolt::Error::General})); + + auto result = impl.subscribeOnResolutionChanged([](const Firebolt::VideoOutput::VideoOutputResolution& /*value*/) {}); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error(), Firebolt::Error::General); +} + +TEST_F(VideooutputGeneratedUTest, SubscribeOnHdcpChangedForwardsAndDispatchesParsedPayload) +{ + bool notified = false; + Firebolt::VideoOutput::HdcpState received = Firebolt::VideoOutput::HdcpState::None; + + EXPECT_CALL(mockHelper, subscribe(&impl, "VideoOutput.onHdcpChanged", ::testing::_, ::testing::_)) + .WillOnce(::testing::Invoke( + [&](void* owner, const std::string& eventName, std::any&& notification, + void (*callback)(void*, const nlohmann::json&)) + { + Firebolt::Helpers::SubscriptionData data{owner, eventName, std::move(notification)}; + callback(&data, nlohmann::json(static_cast(Firebolt::VideoOutput::HdcpState::Direct))); + return Firebolt::Result{11}; + })); + + auto result = impl.subscribeOnHdcpChanged( + [&](const Firebolt::VideoOutput::HdcpState& value) + { + notified = true; + received = value; + }); + + ASSERT_TRUE(result); + EXPECT_EQ(*result, 11U); + EXPECT_TRUE(notified); + EXPECT_EQ(received, Firebolt::VideoOutput::HdcpState::Direct); +} + +TEST_F(VideooutputGeneratedUTest, SubscribeOnHdcpChangedForwardsSubscribeErrors) +{ + EXPECT_CALL(mockHelper, subscribe(&impl, "VideoOutput.onHdcpChanged", ::testing::_, ::testing::_)) + .WillOnce(::testing::Return(Firebolt::Result{Firebolt::Error::General})); + + auto result = impl.subscribeOnHdcpChanged([](const Firebolt::VideoOutput::HdcpState& /*value*/) {}); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error(), Firebolt::Error::General); +} + +TEST_F(VideooutputGeneratedUTest, SubscribeOnCecStateChangedForwardsAndDispatchesParsedPayload) +{ + bool notified = false; + Firebolt::VideoOutput::CecStateValue received = Firebolt::VideoOutput::CecStateValue::Unsupported; + + EXPECT_CALL(mockHelper, subscribe(&impl, "VideoOutput.onCecStateChanged", ::testing::_, ::testing::_)) + .WillOnce(::testing::Invoke( + [&](void* owner, const std::string& eventName, std::any&& notification, + void (*callback)(void*, const nlohmann::json&)) + { + Firebolt::Helpers::SubscriptionData data{owner, eventName, std::move(notification)}; + callback(&data, nlohmann::json(static_cast(Firebolt::VideoOutput::CecStateValue::Active))); + return Firebolt::Result{12}; + })); + + auto result = impl.subscribeOnCecStateChanged( + [&](const Firebolt::VideoOutput::CecStateValue& value) + { + notified = true; + received = value; + }); + + ASSERT_TRUE(result); + EXPECT_EQ(*result, 12U); + EXPECT_TRUE(notified); + EXPECT_EQ(received, Firebolt::VideoOutput::CecStateValue::Active); +} + +TEST_F(VideooutputGeneratedUTest, SubscribeOnCecStateChangedForwardsSubscribeErrors) +{ + EXPECT_CALL(mockHelper, subscribe(&impl, "VideoOutput.onCecStateChanged", ::testing::_, ::testing::_)) + .WillOnce(::testing::Return(Firebolt::Result{Firebolt::Error::General})); + + auto result = impl.subscribeOnCecStateChanged([](const Firebolt::VideoOutput::CecStateValue& /*value*/) {}); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error(), Firebolt::Error::General); +} + +TEST_F(VideooutputGeneratedUTest, SubscribeOnRefreshRateChangedForwardsAndDispatchesParsedPayload) +{ + bool notified = false; + Firebolt::VideoOutput::RefreshRateValue received = Firebolt::VideoOutput::RefreshRateValue::R0; + + EXPECT_CALL(mockHelper, subscribe(&impl, "VideoOutput.onRefreshRateChanged", ::testing::_, ::testing::_)) + .WillOnce(::testing::Invoke( + [&](void* owner, const std::string& eventName, std::any&& notification, + void (*callback)(void*, const nlohmann::json&)) + { + Firebolt::Helpers::SubscriptionData data{owner, eventName, std::move(notification)}; + callback(&data, nlohmann::json(static_cast(Firebolt::VideoOutput::RefreshRateValue::R24))); + return Firebolt::Result{13}; + })); + + auto result = impl.subscribeOnRefreshRateChanged( + [&](const Firebolt::VideoOutput::RefreshRateValue& value) + { + notified = true; + received = value; + }); + + ASSERT_TRUE(result); + EXPECT_EQ(*result, 13U); + EXPECT_TRUE(notified); + EXPECT_EQ(received, Firebolt::VideoOutput::RefreshRateValue::R24); +} + +TEST_F(VideooutputGeneratedUTest, SubscribeOnRefreshRateChangedForwardsSubscribeErrors) +{ + EXPECT_CALL(mockHelper, subscribe(&impl, "VideoOutput.onRefreshRateChanged", ::testing::_, ::testing::_)) + .WillOnce(::testing::Return(Firebolt::Result{Firebolt::Error::General})); + + auto result = impl.subscribeOnRefreshRateChanged([](const Firebolt::VideoOutput::RefreshRateValue& /*value*/) {}); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error(), Firebolt::Error::General); +}