diff --git a/.docker/desktop-apps.bake.Dockerfile b/.docker/desktop-apps.bake.Dockerfile index 8ab80540c..94eea251e 100644 --- a/.docker/desktop-apps.bake.Dockerfile +++ b/.docker/desktop-apps.bake.Dockerfile @@ -102,7 +102,17 @@ FROM core-base AS desktop-linux -DVCPKG_MANIFEST_FEATURES="desktop-editors" \ -DABOUT_PAGE_APP_NAME="${ABOUT_PAGE_APP_NAME}" \ /desktop-apps/win-linux/ && \ - cmake --build . && \ + cmake --build . + + # Split from the compile step above so ctest's own output isn't sharing (and + # getting pushed out of) BuildKit's per-step 2MiB log buffer with the compiler's + # warning noise -- that clipping repeatedly hid ctest's actual per-test results + # during the gateway test suite's rollout. + RUN --mount=type=cache,target=/build-cache-desktop,id=build-cache-desktop-${CACHE_BUST} \ + --mount=type=cache,target=/ccache,id=ccache \ + export CCACHE_DIR=/ccache && \ + cd /build-cache-desktop && \ + ctest --test-dir . --output-on-failure && \ cmake --install . && \ ccache --show-stats && \ cp -a desktopeditors /desktopeditors diff --git a/win-linux/CMakeLists.txt b/win-linux/CMakeLists.txt index b256bacdf..b920e6642 100644 --- a/win-linux/CMakeLists.txt +++ b/win-linux/CMakeLists.txt @@ -27,6 +27,13 @@ if(UNIX) add_definitions(-DLINUX -D_LINUX -D_WAYLAND) endif() +# Gateway (see cdp-gateway-cli-plan.md): GatewayCommandRunner drives CDP in-process via +# CCefView::SendGatewayDevToolsMessage (CefBrowserHost::SendDevToolsMessage under the +# hood) -- no external --remote-debugging-port, no WebSocket client needed. An earlier +# design used Qt6WebSockets against an external CDP port; dropped after investigation +# showed the in-process CefBrowserHost API solves target resolution for free (see +# gatewaycommandrunner.h and the desktop-sdk cefview.h/.cpp changes on this branch). + if(NOT TARGET hunspell-wrapper) add_subdirectory(${CORE_ROOT_DIR}/Common/3dParty/hunspell/qt hunspell-wrapper) endif() @@ -115,6 +122,14 @@ if(NOT TARGET allthemesgen) add_subdirectory( "${CORE_ROOT_DIR}/DesktopEditor/allthemesgen" allthemesgen ) endif() +if(NOT TARGET eo-ctl) + add_subdirectory( "${CMAKE_CURRENT_LIST_DIR}/tools/eo-ctl/build/cmake" eo-ctl ) +endif() + +enable_testing() +add_subdirectory( "${CMAKE_CURRENT_LIST_DIR}/tests/gateway" gateway_tests ) +add_subdirectory( "${CMAKE_CURRENT_LIST_DIR}/tools/eo-ctl/tests" eo_ctl_tests ) + # 3. Definitions & Global Config add_definitions(-D__DONT_WRITE_IN_APP_TITLE) add_definitions(-DAPP_ICON_PATH="./res/icons/desktopeditors-eo.ico") @@ -131,6 +146,14 @@ endif() # 4. Source and Header Lists set(COMMON_HEADERS + src/gateway/gatewaytypes.h + src/gateway/allowlist.h + src/gateway/gatewaycommandrunner.h + src/gateway/gatewayserver.h + src/gateway/commands/wordcommands.h + src/gateway/commands/cellcommands.h + src/gateway/commands/slidecommands.h + src/gateway/commands/pdfcommands.h src/prop/defines_p.h src/prop/cascapplicationmanagerwrapperintf.h src/prop/version_p.h @@ -180,6 +203,13 @@ set(COMMON_HEADERS ) set(COMMON_SOURCES + src/gateway/allowlist.cpp + src/gateway/gatewaycommandrunner.cpp + src/gateway/gatewayserver.cpp + src/gateway/commands/wordcommands.cpp + src/gateway/commands/cellcommands.cpp + src/gateway/commands/slidecommands.cpp + src/gateway/commands/pdfcommands.cpp src/prop/cmainwindowimpl.cpp src/prop/utils.cpp src/windows/cmainwindow.cpp @@ -445,6 +475,7 @@ install(TARGETS DesktopEditors ascdocumentscore qtascdocumentscore + eo-ctl LIBRARY DESTINATION ${CMAKE_INSTALL_BINDIR} RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} ) diff --git a/win-linux/src/gateway/allowlist.cpp b/win-linux/src/gateway/allowlist.cpp new file mode 100644 index 000000000..f52bd1c4f --- /dev/null +++ b/win-linux/src/gateway/allowlist.cpp @@ -0,0 +1,30 @@ +#include "allowlist.h" + +namespace Gateway +{ + AllowlistTable& AllowlistTable::Instance() + { + static AllowlistTable instance; + return instance; + } + + void AllowlistTable::Register(const QString& command, CommandSpec spec) + { + m_commands.emplace(command, std::move(spec)); + } + + const CommandSpec* AllowlistTable::Find(const QString& command) const + { + auto it = m_commands.find(command); + return it == m_commands.end() ? nullptr : &it->second; + } + + std::vector AllowlistTable::ListCommandNames() const + { + std::vector names; + names.reserve(m_commands.size()); + for (const auto& entry : m_commands) + names.push_back(entry.first); + return names; + } +} diff --git a/win-linux/src/gateway/allowlist.h b/win-linux/src/gateway/allowlist.h new file mode 100644 index 000000000..25e229d59 --- /dev/null +++ b/win-linux/src/gateway/allowlist.h @@ -0,0 +1,76 @@ +#ifndef GATEWAY_ALLOWLIST_H +#define GATEWAY_ALLOWLIST_H + +#include +#include +#include +#include + +#include "gatewaytypes.h" + +// One entry per allowlisted command. `validate` is a hand-rolled schema check (no +// generic JSON-schema engine — see cdp-gateway-cli-plan.md §9/YAGNI: this app has no +// existing JSON-schema dependency, and the schemas in gateway-test-case-designs.md are +// simple enough not to need one). `script` is the apiBuilder.js-backed script template; +// its only substitution point is the literal token %%SCOPE%%, filled by +// GatewayCommandRunner with QJsonDocument(scope).toJson(Compact) — never by +// interpolating individual scope fields into the template string. See §2 of the plan. +namespace Gateway +{ + struct CommandSpec + { + // Returns an empty QString if `scope` is valid, otherwise a human-readable reason. + std::function validate; + QString script; + }; + + class AllowlistTable + { + public: + static AllowlistTable& Instance(); + + // Registers a command. Called from each *Commands.cpp's static registration + // block (WordCommands.cpp, CellCommands.cpp, SlideCommands.cpp, PdfCommands.cpp). + void Register(const QString& command, CommandSpec spec); + + // Returns nullptr if `command` is not allowlisted. + const CommandSpec* Find(const QString& command) const; + + // eo-ctl's `allowlist` subcommand and the gateway's introspection endpoint both + // read this — command names only, no schema/script internals leaked. + std::vector ListCommandNames() const; + + private: + std::map m_commands; + }; + + // --- small reusable scope-field validators, shared across command families --- + + inline QString RequireInt(const QJsonObject& scope, const QString& field, int minimum = 0) + { + if (!scope.contains(field) || !scope.value(field).isDouble()) + return QStringLiteral("scope.%1 must be an integer").arg(field); + double v = scope.value(field).toDouble(); + if (v != static_cast(v) || static_cast(v) < minimum) + return QStringLiteral("scope.%1 must be an integer >= %2").arg(field).arg(minimum); + return QString(); + } + + inline QString RequireString(const QJsonObject& scope, const QString& field, bool allowEmpty = true) + { + if (!scope.contains(field) || !scope.value(field).isString()) + return QStringLiteral("scope.%1 must be a string").arg(field); + if (!allowEmpty && scope.value(field).toString().isEmpty()) + return QStringLiteral("scope.%1 must not be empty").arg(field); + return QString(); + } + + inline QString RequireBool(const QJsonObject& scope, const QString& field) + { + if (!scope.contains(field) || !scope.value(field).isBool()) + return QStringLiteral("scope.%1 must be a boolean").arg(field); + return QString(); + } +} + +#endif // GATEWAY_ALLOWLIST_H diff --git a/win-linux/src/gateway/commands/cellcommands.cpp b/win-linux/src/gateway/commands/cellcommands.cpp new file mode 100644 index 000000000..496cf93a9 --- /dev/null +++ b/win-linux/src/gateway/commands/cellcommands.cpp @@ -0,0 +1,927 @@ +#include "cellcommands.h" +#include "../allowlist.h" + +#include +#include + +// Cell command family, implemented in the sequential order set by +// cdp-gateway-cli-plan.md §4 ("Cell (second)"). Each command's test cases live in +// gateway-test-case-designs.md under the matching §C heading (this file: §C1). +// +// Script bodies were written against sdkjs/cell/apiBuilder.js as it actually reads -- +// not guessed. Every %%SCOPE%% is the sole substitution point, filled by +// GatewayCommandRunner via QJsonDocument(scope).toJson(Compact), never per-field +// string interpolation. + +namespace Gateway::Commands +{ + void RegisterCellCommands() + { + auto& table = AllowlistTable::Instance(); + + // --- C1. Sheet management --- + // + // Api.AddSheet(sName) (apiBuilder.js:777, top-level Api, not ApiWorkbook) + // throws if a sheet with that name already exists. Api.GetSheets() (799) + // returns ApiWorksheet[], not JSON-serializable -- returned as an array of + // names via ApiWorksheet.GetName() (8546), same pattern as word.getAllTables + // etc. ApiWorksheet.SetActive() (8332) is on the worksheet, not + // ApiWorkbook.SetActiveSheet (no such method exists). Api.GetSheet(name) + // (867) resolves a sheet by name for target resolution. + + table.Register(QStringLiteral("cell.addSheet"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireString(scope, QStringLiteral("name"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + Api.AddSheet(scope.name); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.getSheets"), CommandSpec{ + [](const QJsonObject&) -> QString { return QString(); }, + QStringLiteral(R"js( + (function(scope){ + return Api.GetSheets().map(function(ws){ return ws.GetName(); }); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.setActiveSheet"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireString(scope, QStringLiteral("name"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + var ws = Api.GetSheet(scope.name); + if (!ws) throw new Error("no sheet named " + scope.name); + ws.SetActive(); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.getActiveSheet"), CommandSpec{ + [](const QJsonObject&) -> QString { return QString(); }, + QStringLiteral(R"js( + (function(scope){ + return Api.GetActiveSheet().GetName(); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.setVisible"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("name"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireBool(scope, QStringLiteral("visible")); + }, + QStringLiteral(R"js( + (function(scope){ + var ws = Api.GetSheet(scope.name); + if (!ws) throw new Error("no sheet named " + scope.name); + ws.SetVisible(scope.visible); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.setName"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("oldName"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("newName"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + var ws = Api.GetSheet(scope.oldName); + if (!ws) throw new Error("no sheet named " + scope.oldName); + ws.SetName(scope.newName); + return null; + })(%%SCOPE%%); + )js") + }); + + // --- C2. Cell/range read & write --- + // + // ApiWorksheet.GetRange(Range1) (apiBuilder.js:8602) resolves a range string + // on a given sheet, throwing if it can't. ApiRange.SetValue (10161) is the + // only setter -- no separate SetFormula exists; a value string starting with + // "=" becomes a formula through this same call. GetFormula (10241) returns + // "= " + the formula text, with a literal space -- preserved, not assumed away. + + auto resolveRange = QStringLiteral(R"js( + var ws = Api.GetSheet(scope.sheet); + if (!ws) throw new Error("no sheet named " + scope.sheet); + var range = ws.GetRange(scope.range); + if (!range) throw new Error("could not resolve range " + scope.range); + )js"); + + table.Register(QStringLiteral("cell.setValue"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("range"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + if (!scope.contains(QStringLiteral("value")) || + !(scope.value(QStringLiteral("value")).isString() || + scope.value(QStringLiteral("value")).isDouble() || + scope.value(QStringLiteral("value")).isBool())) + return QStringLiteral("scope.value must be a string, number, or boolean"); + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveRange + QStringLiteral(R"js( + if (!range.SetValue(scope.value)) throw new Error("SetValue failed (protected sheet or invalid range)"); + return true; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.getValue"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("range"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveRange + QStringLiteral(R"js( + return range.GetValue(); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.getFormula"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("range"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveRange + QStringLiteral(R"js( + return range.GetFormula(); + })(%%SCOPE%%); + )js") + }); + + // --- C3. Number formats, merge, clear --- + // + // SetNumberFormat/Merge/ClearContents (apiBuilder.js:10828,10897,9759) all + // return null/undefined on success -- no boolean result to surface. + + table.Register(QStringLiteral("cell.setNumberFormat"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("range"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("format"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveRange + QStringLiteral(R"js( + range.SetNumberFormat(scope.format); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.merge"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("range"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireBool(scope, QStringLiteral("across")); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveRange + QStringLiteral(R"js( + range.Merge(scope.across); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.clearContents"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("range"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveRange + QStringLiteral(R"js( + range.ClearContents(); + return null; + })(%%SCOPE%%); + )js") + }); + + // --- C4. Copy/paste, find/replace --- + // + // ApiRange.Copy(destination) (apiBuilder.js:11338) needs a real ApiRange + // destination, not a string. Find/Replace (11616, 11764) are per-range + // methods, not document-wide search -- Find returns a single ApiRange | null + // (first match), not a list; both are called here against + // ApiWorksheet.GetUsedRange() (8524) as the search scope. Results reported as + // an address string (ApiRange.GetAddress(), 10043) or null, not the + // unserializable ApiRange handle. + + table.Register(QStringLiteral("cell.copy"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("from"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("to"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + var ws = Api.GetSheet(scope.sheet); + if (!ws) throw new Error("no sheet named " + scope.sheet); + var src = ws.GetRange(scope.from); + var dst = ws.GetRange(scope.to); + if (!src || !dst) throw new Error("could not resolve from/to range"); + src.Copy(dst); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.find"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("text"), /*allowEmpty=*/true); + }, + QStringLiteral(R"js( + (function(scope){ + var ws = Api.GetSheet(scope.sheet); + if (!ws) throw new Error("no sheet named " + scope.sheet); + var found = ws.GetUsedRange().Find({What: scope.text}); + return found ? found.GetAddress() : null; + })(%%SCOPE%%); + )js") + }); + + // --- C5. Font/fill/border/alignment formatting --- + // + // SetFontName (apiBuilder.js:10513) takes a plain string. SetFillColor (10772) + // and SetBorders (§C3's investigation, same file) both need a real ApiColor, + // built via Api.CreateColorFromRGB (925) from the scope's #RRGGBB hex, same + // decomposition as word.setColor (§B4). SetBorders has no "all" edge mode -- + // the script loops over the four outer edges itself. SetAlignHorizontal (10575) + // returns false (not a throw) for an unrecognized value; the script throws in + // that case to keep this gateway's own error contract consistent. + + table.Register(QStringLiteral("cell.setFontName"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("range"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("font"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveRange + QStringLiteral(R"js( + range.SetFontName(scope.font); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.setFillColor"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("range"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + static const QRegularExpression hexColor(QStringLiteral("^#[0-9A-Fa-f]{6}$")); + const QJsonValue colorValue = scope.value(QStringLiteral("color")); + if (!colorValue.isString() || !hexColor.match(colorValue.toString()).hasMatch()) + return QStringLiteral("scope.color must be a #RRGGBB hex string"); + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveRange + QStringLiteral(R"js( + var hex = scope.color.replace('#', ''); + var r = parseInt(hex.substring(0, 2), 16); + var g = parseInt(hex.substring(2, 4), 16); + var b = parseInt(hex.substring(4, 6), 16); + range.SetFillColor(Api.CreateColorFromRGB(r, g, b)); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.setBorders"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("range"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + static const QStringList validEdges = { + QStringLiteral("all"), QStringLiteral("DiagonalDown"), QStringLiteral("DiagonalUp"), + QStringLiteral("Bottom"), QStringLiteral("Left"), QStringLiteral("Right"), QStringLiteral("Top"), + QStringLiteral("InsideHorizontal"), QStringLiteral("InsideVertical") + }; + err = RequireString(scope, QStringLiteral("edge"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + if (!validEdges.contains(scope.value(QStringLiteral("edge")).toString())) + return QStringLiteral("scope.edge is not a recognized border edge"); + err = RequireString(scope, QStringLiteral("style"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + static const QRegularExpression hexColor(QStringLiteral("^#[0-9A-Fa-f]{6}$")); + const QJsonValue colorValue = scope.value(QStringLiteral("color")); + if (!colorValue.isString() || !hexColor.match(colorValue.toString()).hasMatch()) + return QStringLiteral("scope.color must be a #RRGGBB hex string"); + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveRange + QStringLiteral(R"js( + var hex = scope.color.replace('#', ''); + var r = parseInt(hex.substring(0, 2), 16); + var g = parseInt(hex.substring(2, 4), 16); + var b = parseInt(hex.substring(4, 6), 16); + var color = Api.CreateColorFromRGB(r, g, b); + var edges = (scope.edge === 'all') ? ['Top', 'Bottom', 'Left', 'Right'] : [scope.edge]; + for (var i = 0; i < edges.length; i++) { + range.SetBorders(edges[i], scope.style, color); + } + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.setAlignHorizontal"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("range"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + static const QStringList validAligns = { + QStringLiteral("left"), QStringLiteral("right"), + QStringLiteral("center"), QStringLiteral("justify") + }; + err = RequireString(scope, QStringLiteral("align"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + if (!validAligns.contains(scope.value(QStringLiteral("align")).toString())) + return QStringLiteral("scope.align must be one of left, right, center, justify"); + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveRange + QStringLiteral(R"js( + if (!range.SetAlignHorizontal(scope.align)) throw new Error("SetAlignHorizontal rejected the given alignment"); + return null; + })(%%SCOPE%%); + )js") + }); + + // --- C6. Conditional formatting --- + // + // ApiRange.GetFormatConditions() (apiBuilder.js:12827) returns the + // ApiFormatConditions collection; Add* methods (AddColorScale, AddDatabar, + // AddIconSetCondition, 21119/21229/21299) return the created rule or null, + // not JSON-serializable -- surfaced as a boolean. AddIconSetCondition takes no + // parameters -- the originally-planned iconSet scope field dropped rather than + // guessed at (see gateway-test-case-designs.md §C6). + + table.Register(QStringLiteral("cell.addColorScale"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("range"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireInt(scope, QStringLiteral("scaleType"), /*minimum=*/2); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveRange + QStringLiteral(R"js( + return !!range.GetFormatConditions().AddColorScale(scope.scaleType); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.addDatabar"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("range"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveRange + QStringLiteral(R"js( + return !!range.GetFormatConditions().AddDatabar(); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.addIconSetCondition"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("range"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveRange + QStringLiteral(R"js( + return !!range.GetFormatConditions().AddIconSetCondition(); + })(%%SCOPE%%); + )js") + }); + + // --- C7. Data validation and named ranges --- + // + // ApiRange.GetValidation().Add(...) (apiBuilder.js:12793, 19898) takes the + // real internal enum strings (FromXlValidationTypeTo/FromXlValidationOperatorTo, + // 19653/19747), e.g. "xlValidateWholeNumber"/"xlBetween" -- accepted verbatim + // in scope rather than inventing a translation layer. ApiWorksheet.AddDefName + // (8974) returns false (not a throw) for an invalid name/ref -- converted to a + // thrown error here to keep this gateway's own error contract consistent. + + table.Register(QStringLiteral("cell.addValidation"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("range"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("type"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("operator"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("formula1"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("formula2"), /*allowEmpty=*/true); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveRange + QStringLiteral(R"js( + var v = range.GetValidation().Add(scope.type, undefined, scope.operator, scope.formula1, scope.formula2 || undefined); + if (!v) throw new Error("Add validation failed (unrecognized type/operator, or validation already exists)"); + return true; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.addDefName"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("name"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("refersTo"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + var refersTo = scope.refersTo; + var sheetName = refersTo.split('!')[0]; + var ws = Api.GetSheet(sheetName); + if (!ws) throw new Error("no sheet named " + sheetName); + if (!ws.AddDefName(scope.name, refersTo, false)) + throw new Error("AddDefName rejected the given name/refersTo"); + return true; + })(%%SCOPE%%); + )js") + }); + + // --- C8. AutoFilter --- + // + // ApiAutoFilter.ApplyFilter() (apiBuilder.js:27379) only re-evaluates an + // *existing* AutoFilter's criteria -- it does not create one. Establishing a + // new AutoFilter range is ApiRange.SetAutoFilter() with no arguments (12216), + // which toggles: creates one if none exists, deletes the existing one if + // called again. GetFilters() (27421) returns unserializable ApiFilter[] -- + // cell.getFilters returns GetFilterMode()'s boolean instead. + + table.Register(QStringLiteral("cell.applyFilter"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("range"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveRange + QStringLiteral(R"js( + range.SetAutoFilter(); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.getFilters"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + var ws = Api.GetSheet(scope.sheet); + if (!ws) throw new Error("no sheet named " + scope.sheet); + return ws.GetAutoFilter().GetFilterMode(); + })(%%SCOPE%%); + )js") + }); + + // --- C9. PivotTable --- + // + // Three real, distinct steps (apiBuilder.js:7676,16192,17582): create via + // Api.InsertPivotExistingWorksheet(dataRef, pivotRef, confirmation) (real + // ApiRange objects, not strings), name it (SetName, 16782) so later commands + // can re-resolve it via ApiWorksheet.GetPivotByName (9412) -- there's no + // addressing by source range. AddDataField (16192) returns an + // ApiPivotDataField; ApiPivotField.SetFunction (the field type AddFields + // works with) is a hardcoded-error stub -- the real setter is + // ApiPivotDataField.SetFunction (17582), re-resolved via GetDataFields + // (16633) for a later, separate call. private_MakeError really throws + // (throwException), so AddDataField on an unknown field already propagates + // as SCRIPT_EXCEPTION with no extra null-check needed. + + table.Register(QStringLiteral("cell.addPivotTable"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + for (const char* field : {"sourceSheet", "sourceRange", "pivotSheet", "pivotRange", "name"}) + { + const QString err = RequireString(scope, QString::fromLatin1(field), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + } + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + var srcWs = Api.GetSheet(scope.sourceSheet); + if (!srcWs) throw new Error("no sheet named " + scope.sourceSheet); + var pivotWs = Api.GetSheet(scope.pivotSheet); + if (!pivotWs) throw new Error("no sheet named " + scope.pivotSheet); + var dataRef = srcWs.GetRange(scope.sourceRange); + var pivotRef = pivotWs.GetRange(scope.pivotRange); + if (!dataRef || !pivotRef) throw new Error("could not resolve source/pivot range"); + var table = Api.InsertPivotExistingWorksheet(dataRef, pivotRef, true); + if (!table) throw new Error("InsertPivotExistingWorksheet failed"); + table.SetName(scope.name); + return true; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.addPivotDataField"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("pivotName"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("field"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("func"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + var ws = Api.GetSheet(scope.sheet); + if (!ws) throw new Error("no sheet named " + scope.sheet); + var table = ws.GetPivotByName(scope.pivotName); + if (!table) throw new Error("no pivot table named " + scope.pivotName); + var dataField = table.AddDataField(scope.field); + dataField.SetFunction(scope.func); + return true; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.setPivotFieldFunction"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("pivotName"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("field"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("func"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + var ws = Api.GetSheet(scope.sheet); + if (!ws) throw new Error("no sheet named " + scope.sheet); + var table = ws.GetPivotByName(scope.pivotName); + if (!table) throw new Error("no pivot table named " + scope.pivotName); + var dataField = table.GetDataFields(scope.field); + if (!dataField) throw new Error("no data field " + scope.field); + dataField.SetFunction(scope.func); + return true; + })(%%SCOPE%%); + )js") + }); + + // --- C10. Freeze panes --- + // + // ApiWorksheet.GetFreezePanes().FreezeAt(range) (apiBuilder.js:9474,15780) -- + // range resolved explicitly via ws.GetRange() first rather than relying on + // FreezeAt's own string-overload, which resolves against the *active* sheet, + // not necessarily the sheet this command's `sheet` scope field names. + + table.Register(QStringLiteral("cell.freezeAt"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("range"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveRange + QStringLiteral(R"js( + ws.GetFreezePanes().FreezeAt(range); + return null; + })(%%SCOPE%%); + )js") + }); + + // --- C11. Insert images/OLE objects (shapes deferred) --- + // + // ApiWorksheet.AddImage/AddOleObject (apiBuilder.js:9167,9228) place objects + // by column/row + EMU offset, not a range; sImageSrc is a URL/base64 data URI + // (same as word.createImage, §B9), not a local file path. cell.addShape is + // deliberately NOT implemented -- AddShape needs real ApiFill/ApiStroke + // objects whose constructors weren't confirmed in this file this pass; see + // gateway-test-case-designs.md §C11. + + auto imagePlacementFields = std::vector{ + "fromCol", "colOffset", "fromRow", "rowOffset" + }; + + table.Register(QStringLiteral("cell.addImage"), CommandSpec{ + [imagePlacementFields](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("imageSrc"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireInt(scope, QStringLiteral("width"), /*minimum=*/1); + if (!err.isEmpty()) return err; + err = RequireInt(scope, QStringLiteral("height"), /*minimum=*/1); + if (!err.isEmpty()) return err; + for (const char* field : imagePlacementFields) + { + err = RequireInt(scope, QString::fromLatin1(field)); + if (!err.isEmpty()) return err; + } + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + var ws = Api.GetSheet(scope.sheet); + if (!ws) throw new Error("no sheet named " + scope.sheet); + var image = ws.AddImage(scope.imageSrc, scope.width, scope.height, + scope.fromCol, scope.colOffset, scope.fromRow, scope.rowOffset); + return !!image; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.addOleObject"), CommandSpec{ + [imagePlacementFields](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("imageSrc"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireInt(scope, QStringLiteral("width"), /*minimum=*/1); + if (!err.isEmpty()) return err; + err = RequireInt(scope, QStringLiteral("height"), /*minimum=*/1); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("data"), /*allowEmpty=*/true); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("appId"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + for (const char* field : imagePlacementFields) + { + err = RequireInt(scope, QString::fromLatin1(field)); + if (!err.isEmpty()) return err; + } + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + var ws = Api.GetSheet(scope.sheet); + if (!ws) throw new Error("no sheet named " + scope.sheet); + var ole = ws.AddOleObject(scope.imageSrc, scope.width, scope.height, + scope.data, scope.appId, scope.fromCol, scope.colOffset, scope.fromRow, scope.rowOffset); + return !!ole; + })(%%SCOPE%%); + )js") + }); + + // --- C12. Comments with replies --- + // + // ApiRange.AddComment (apiBuilder.js:10969) returns ApiComment|null. + // ApiComment has no public row/col accessor -- addReply/setSolved address a + // comment by the id AddComment/GetId() (13921) returns, resolved via + // ws.GetComments().find(...), not by range (no real lookup for that exists). + + auto resolveComment = QStringLiteral(R"js( + var ws = Api.GetSheet(scope.sheet); + if (!ws) throw new Error("no sheet named " + scope.sheet); + var comment = ws.GetComments().find(function(c){ return c.GetId() === scope.commentId; }); + if (!comment) throw new Error("no comment with id " + scope.commentId); + )js"); + + table.Register(QStringLiteral("cell.addComment"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("range"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("text"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("author"), /*allowEmpty=*/true); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveRange + QStringLiteral(R"js( + var comment = range.AddComment(scope.text, scope.author); + if (!comment) throw new Error("AddComment failed"); + return comment.GetId(); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.addReply"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("commentId"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("text"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("author"), /*allowEmpty=*/true); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveComment + QStringLiteral(R"js( + comment.AddReply(scope.text, scope.author); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.setSolved"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("commentId"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireBool(scope, QStringLiteral("solved")); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveComment + QStringLiteral(R"js( + comment.SetSolved(scope.solved); + return null; + })(%%SCOPE%%); + )js") + }); + + // --- C13. Insert/delete rows and columns --- + // + // ApiWorksheet.GetRangeByNumber(nRow, nCol) (apiBuilder.js:8642) resolves + // 0-based grid coordinates directly, matching this document's row/col index + // convention -- anchors GetEntireRow()/GetEntireColumn() (12753,12775) before + // Insert/Delete (11280,11241). + + table.Register(QStringLiteral("cell.insertEntireRow"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireInt(scope, QStringLiteral("rowIndex")); + }, + QStringLiteral(R"js( + (function(scope){ + var ws = Api.GetSheet(scope.sheet); + if (!ws) throw new Error("no sheet named " + scope.sheet); + ws.GetRangeByNumber(scope.rowIndex, 0).GetEntireRow().Insert("down"); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.deleteEntireColumn"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireInt(scope, QStringLiteral("colIndex")); + }, + QStringLiteral(R"js( + (function(scope){ + var ws = Api.GetSheet(scope.sheet); + if (!ws) throw new Error("no sheet named " + scope.sheet); + ws.GetRangeByNumber(0, scope.colIndex).GetEntireColumn().Delete("left"); + return null; + })(%%SCOPE%%); + )js") + }); + + // --- C14. Recalculate formulas --- + // + // Api.RecalculateAllFormulas(fLogger) (apiBuilder.js:7586) matches the plan + // exactly -- no correction needed. + + table.Register(QStringLiteral("cell.recalculateAllFormulas"), CommandSpec{ + [](const QJsonObject&) -> QString { return QString(); }, + QStringLiteral(R"js( + (function(scope){ + return Api.RecalculateAllFormulas(); + })(%%SCOPE%%); + )js") + }); + + // --- C15. Create charts and edit data series --- + // + // ApiWorksheet.GetAllCharts() (apiBuilder.js:9359) is the only real + // addressing mechanism -- ApiChart has no GetName/SetName at all, so + // chartIndex-into-GetAllCharts() isn't a workaround, it's the sole option. + // AddSeria/SetSeriaName (13641,13608) take range strings, not a single + // range/name scalar as originally planned for a couple of the params. + + auto resolveChart = QStringLiteral(R"js( + var ws = Api.GetSheet(scope.sheet); + if (!ws) throw new Error("no sheet named " + scope.sheet); + var chart = ws.GetAllCharts()[scope.chartIndex]; + if (!chart) throw new Error("no chart at chartIndex " + scope.chartIndex); + )js"); + + table.Register(QStringLiteral("cell.addSeria"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireInt(scope, QStringLiteral("chartIndex")); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("valuesRange"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveChart + QStringLiteral(R"js( + chart.AddSeria("", scope.valuesRange); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.setSeriaName"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireInt(scope, QStringLiteral("chartIndex")); + if (!err.isEmpty()) return err; + err = RequireInt(scope, QStringLiteral("seriaIndex")); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("name"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveChart + QStringLiteral(R"js( + return chart.SetSeriaName(scope.name, scope.seriaIndex); + })(%%SCOPE%%); + )js") + }); + + // --- C16. Read SmartArt object type --- + // + // ApiSmartArt (apiBuilder.js:13294) is one variant of the generic Drawing + // typedef -- no SmartArt-only collection exists; index addresses into + // ApiWorksheet.GetAllDrawings() (9271), the generic mixed-type collection. + + table.Register(QStringLiteral("cell.getSmartArtClassType"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireInt(scope, QStringLiteral("index")); + }, + QStringLiteral(R"js( + (function(scope){ + var ws = Api.GetSheet(scope.sheet); + if (!ws) throw new Error("no sheet named " + scope.sheet); + return ws.GetAllDrawings()[scope.index].GetClassType(); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("cell.replace"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("sheet"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("find"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("replace"), /*allowEmpty=*/true); + }, + QStringLiteral(R"js( + (function(scope){ + var ws = Api.GetSheet(scope.sheet); + if (!ws) throw new Error("no sheet named " + scope.sheet); + var found = ws.GetUsedRange().Replace({What: scope.find, Replacement: scope.replace, ReplaceAll: true}); + return found ? found.GetAddress() : null; + })(%%SCOPE%%); + )js") + }); + } +} diff --git a/win-linux/src/gateway/commands/cellcommands.h b/win-linux/src/gateway/commands/cellcommands.h new file mode 100644 index 000000000..f9bbe3c28 --- /dev/null +++ b/win-linux/src/gateway/commands/cellcommands.h @@ -0,0 +1,11 @@ +#ifndef GATEWAY_COMMANDS_CELLCOMMANDS_H +#define GATEWAY_COMMANDS_CELLCOMMANDS_H + +namespace Gateway::Commands +{ + // Registers this family's commands into AllowlistTable::Instance(). See + // wordcommands.h for the registration-call convention. + void RegisterCellCommands(); +} + +#endif // GATEWAY_COMMANDS_CELLCOMMANDS_H diff --git a/win-linux/src/gateway/commands/pdfcommands.cpp b/win-linux/src/gateway/commands/pdfcommands.cpp new file mode 100644 index 000000000..81386b53b --- /dev/null +++ b/win-linux/src/gateway/commands/pdfcommands.cpp @@ -0,0 +1,394 @@ +#include "pdfcommands.h" +#include "../allowlist.h" + +#include + +// PDF command family, implemented in the sequential order set by +// cdp-gateway-cli-plan.md §4 ("PDF (fourth)"). Each command's test cases live in +// gateway-test-case-designs.md under the matching §E heading (this file: §E1). +// +// Script bodies were written against sdkjs/pdf/apiBuilder.js as it actually reads -- +// not guessed. Every %%SCOPE%% is the sole substitution point, filled by +// GatewayCommandRunner via QJsonDocument(scope).toJson(Compact), never per-field +// string interpolation. + +namespace Gateway::Commands +{ + void RegisterPdfCommands() + { + auto& table = AllowlistTable::Instance(); + + // --- E1. Form field read/write --- + // + // ApiDocument.GetAllFields() (apiBuilder.js:1426) returns ApiField[] + // (ApiTextField/ApiCheckboxField/ApiComboboxField/...), not JSON-serializable + // -- returned as field names via GetFullName() (1828), same established + // pattern. GetValue/SetValue (1921,1902) are on the shared ApiBaseField base + // class -- uniform across field types; SetValue stringifies its argument, so + // a checkbox's checked state is a string export value ("Yes"/"Off"), not a + // JSON boolean. GetFieldByName (1452) throws a plain TypeError for an unknown + // name (calls .IsWidget() on whatever GetField returns with no null-check) -- + // that's why an unknown key surfaces as SCRIPT_EXCEPTION with no extra logic + // needed here. + + table.Register(QStringLiteral("pdf.getAllFields"), CommandSpec{ + [](const QJsonObject&) -> QString { return QString(); }, + QStringLiteral(R"js( + (function(scope){ + return Api.GetDocument().GetAllFields().map(function(f){ return f.GetFullName(); }); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("pdf.getFieldValue"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireString(scope, QStringLiteral("key"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + var field = Api.GetDocument().GetFieldByName(scope.key); + if (!field) throw new Error("no field named " + scope.key); + return field.GetValue(); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("pdf.setFieldValue"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("key"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("value"), /*allowEmpty=*/true); + }, + QStringLiteral(R"js( + (function(scope){ + var field = Api.GetDocument().GetFieldByName(scope.key); + if (!field) throw new Error("no field named " + scope.key); + return field.SetValue(scope.value); + })(%%SCOPE%%); + )js") + }); + + // --- E2. Annotations (stamp deferred) --- + // + // Api.CreateHighlightAnnot/CreateUnderlineAnnot/CreateStrikeoutAnnot/ + // CreateFreeTextAnnot/CreateInkAnnot (apiBuilder.js:887,1005,946,594,665) + // create the annotation; ApiPage.AddObject (1605) attaches it. rect is a flat + // [x1,y1,x2,y2] with x1 QString { + const QJsonValue rectValue = scope.value(QStringLiteral("rect")); + if (!rectValue.isArray() || rectValue.toArray().size() != 4) + return QStringLiteral("scope.rect must be an array of 4 numbers [x1,y1,x2,y2]"); + for (const QJsonValue& v : rectValue.toArray()) + if (!v.isDouble()) + return QStringLiteral("scope.rect must be an array of 4 numbers [x1,y1,x2,y2]"); + return QString(); + }; + + table.Register(QStringLiteral("pdf.getAllAnnots"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireInt(scope, QStringLiteral("page")); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolvePage + QStringLiteral(R"js( + return page.GetAllAnnots().map(function(a){ return a.GetClassType(); }); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("pdf.addHighlight"), CommandSpec{ + [requireRect](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("page")); + if (!err.isEmpty()) return err; + return requireRect(scope); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolvePage + QStringLiteral(R"js( + var annot = Api.CreateHighlightAnnot(scope.rect); + page.AddObject(annot); + return true; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("pdf.addUnderline"), CommandSpec{ + [requireRect](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("page")); + if (!err.isEmpty()) return err; + return requireRect(scope); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolvePage + QStringLiteral(R"js( + var annot = Api.CreateUnderlineAnnot(scope.rect); + page.AddObject(annot); + return true; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("pdf.addStrikeout"), CommandSpec{ + [requireRect](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("page")); + if (!err.isEmpty()) return err; + return requireRect(scope); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolvePage + QStringLiteral(R"js( + var annot = Api.CreateStrikeoutAnnot(scope.rect); + page.AddObject(annot); + return true; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("pdf.addFreeText"), CommandSpec{ + [requireRect](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("page")); + if (!err.isEmpty()) return err; + err = requireRect(scope); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("text"), /*allowEmpty=*/true); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolvePage + QStringLiteral(R"js( + var annot = Api.CreateFreeTextAnnot(scope.rect); + annot.SetContents(scope.text); + page.AddObject(annot); + return true; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("pdf.addInk"), CommandSpec{ + [requireRect](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("page")); + if (!err.isEmpty()) return err; + err = requireRect(scope); + if (!err.isEmpty()) return err; + const QJsonValue pathsValue = scope.value(QStringLiteral("paths")); + if (!pathsValue.isArray() || pathsValue.toArray().isEmpty()) + return QStringLiteral("scope.paths must be a non-empty array of [x,y]-pair paths"); + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolvePage + QStringLiteral(R"js( + var inkPaths = scope.paths.map(function(path){ + return path.map(function(pt){ return {x: pt[0], y: pt[1]}; }); + }); + var annot = Api.CreateInkAnnot(scope.rect, inkPaths); + page.AddObject(annot); + return true; + })(%%SCOPE%%); + )js") + }); + + // --- E3. Text search / selection / extraction --- + // + // ApiPage.Search(props) (apiBuilder.js:1659) returns Quad[], where Quad is + // already a flat 8-number array (x1,y1,x2,y2,x3,y3,x4,y4 -- see the typedef at + // line 234), so it round-trips through CDP returnByValue with no conversion, + // unlike the Api* object results elsewhere in this file. ApiPage.SetSelection + // (1690) takes two Point ({x,y}) objects (validated by private_CheckPoint, + // 8147) and must be called before GetSelectedText (1757) has anything to + // return. ApiPage.RecognizeContent (1767) returns ApiDrawing[], not + // JSON-serializable -- converted to GetClassType() per drawing, same pattern + // as annotations in §E2. + + table.Register(QStringLiteral("pdf.searchText"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("page")); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("text"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + if (scope.contains(QStringLiteral("matchCase")) && !scope.value(QStringLiteral("matchCase")).isBool()) + return QStringLiteral("scope.matchCase must be a boolean"); + if (scope.contains(QStringLiteral("wholeWords")) && !scope.value(QStringLiteral("wholeWords")).isBool()) + return QStringLiteral("scope.wholeWords must be a boolean"); + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolvePage + QStringLiteral(R"js( + return page.Search({ + text: scope.text, + matchCase: scope.matchCase || false, + wholeWords: scope.wholeWords || false + }); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("pdf.setSelection"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("page")); + if (!err.isEmpty()) return err; + for (const QString& pointField : {QStringLiteral("startPoint"), QStringLiteral("endPoint")}) + { + const QJsonValue pointValue = scope.value(pointField); + if (!pointValue.isObject() + || !pointValue.toObject().value(QStringLiteral("x")).isDouble() + || !pointValue.toObject().value(QStringLiteral("y")).isDouble()) + return QStringLiteral("scope.%1 must be an object with numeric x and y").arg(pointField); + } + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolvePage + QStringLiteral(R"js( + return page.SetSelection(scope.startPoint, scope.endPoint); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("pdf.getSelectedText"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireInt(scope, QStringLiteral("page")); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolvePage + QStringLiteral(R"js( + return page.GetSelectedText(); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("pdf.recognizeContent"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireInt(scope, QStringLiteral("page")); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolvePage + QStringLiteral(R"js( + return page.RecognizeContent().map(function(d){ return d.GetClassType(); }); + })(%%SCOPE%%); + )js") + }); + + // --- E4. Redaction --- + // + // Two-step workflow, confirmed against source rather than the original + // design's single `pdf.applyRedact{page,rect}` call: Api.CreateRedactAnnot(rect) + // (apiBuilder.js:1123) creates a *pending* redact annotation (same + // AddObject-to-attach pattern as §E2) -- it does NOT itself remove content. + // ApiDocument.SearchAndRedact(props) (1478) marks every doc-wide match of a + // SearchProps text search as pending redact in one call, returning + // ApiRedactAnnotation[] (converted to a count, since GetClassType() would just + // be "redact" repeated -- the count is the useful signal here). + // ApiDocument.ApplyRedact() (1507) is what actually removes the marked + // content; it throws if nothing is pending. There is no per-page ApplyRedact -- + // it applies every pending redact in the document at once. + + table.Register(QStringLiteral("pdf.addRedact"), CommandSpec{ + [requireRect](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("page")); + if (!err.isEmpty()) return err; + return requireRect(scope); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolvePage + QStringLiteral(R"js( + var annot = Api.CreateRedactAnnot(scope.rect); + page.AddObject(annot); + return true; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("pdf.searchAndRedact"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("text"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + if (scope.contains(QStringLiteral("matchCase")) && !scope.value(QStringLiteral("matchCase")).isBool()) + return QStringLiteral("scope.matchCase must be a boolean"); + if (scope.contains(QStringLiteral("wholeWords")) && !scope.value(QStringLiteral("wholeWords")).isBool()) + return QStringLiteral("scope.wholeWords must be a boolean"); + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + var marked = Api.GetDocument().SearchAndRedact({ + text: scope.text, + matchCase: scope.matchCase || false, + wholeWords: scope.wholeWords || false + }); + return marked.length; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("pdf.applyRedact"), CommandSpec{ + [](const QJsonObject&) -> QString { return QString(); }, + QStringLiteral(R"js( + (function(scope){ + return Api.GetDocument().ApplyRedact(); + })(%%SCOPE%%); + )js") + }); + + // --- E5. Page operations --- + // + // ApiDocument.AddPage(nPos, nWidth, nHeight) (apiBuilder.js:1353) clones the + // page at nPos-1 (or nPos if that's out of range) for its default size when + // width/height aren't given, and returns an unserializable ApiPage -- + // converted to its index (GetIndex(), 1582) like every other Api* result in + // this file. ApiDocument.RemovePage(nPos) (1397) returns `false` rather than + // throwing for an out-of-range index -- converted to a thrown Error for the + // consistent SCRIPT_EXCEPTION contract used throughout, same as pdf.setFieldValue + // and the §B/§C/§D equivalents. pdf.getPageCount (not in the original design) + // added via ApiDocument.GetPagesCount() (1414) since it's the only allowlisted + // way to verify AddPage/RemovePage's effect. + + table.Register(QStringLiteral("pdf.getPageCount"), CommandSpec{ + [](const QJsonObject&) -> QString { return QString(); }, + QStringLiteral(R"js( + (function(scope){ + return Api.GetDocument().GetPagesCount(); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("pdf.addPage"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireInt(scope, QStringLiteral("index")); + }, + QStringLiteral(R"js( + (function(scope){ + var page = Api.GetDocument().AddPage(scope.index); + return page.GetIndex(); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("pdf.removePage"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireInt(scope, QStringLiteral("index")); + }, + QStringLiteral(R"js( + (function(scope){ + var removed = Api.GetDocument().RemovePage(scope.index); + if (!removed) throw new Error("no page at index " + scope.index); + return true; + })(%%SCOPE%%); + )js") + }); + } +} diff --git a/win-linux/src/gateway/commands/pdfcommands.h b/win-linux/src/gateway/commands/pdfcommands.h new file mode 100644 index 000000000..31d648520 --- /dev/null +++ b/win-linux/src/gateway/commands/pdfcommands.h @@ -0,0 +1,11 @@ +#ifndef GATEWAY_COMMANDS_PDFCOMMANDS_H +#define GATEWAY_COMMANDS_PDFCOMMANDS_H + +namespace Gateway::Commands +{ + // Registers this family's commands into AllowlistTable::Instance(). See + // wordcommands.h for the registration-call convention. + void RegisterPdfCommands(); +} + +#endif // GATEWAY_COMMANDS_PDFCOMMANDS_H diff --git a/win-linux/src/gateway/commands/slidecommands.cpp b/win-linux/src/gateway/commands/slidecommands.cpp new file mode 100644 index 000000000..86c0f99d1 --- /dev/null +++ b/win-linux/src/gateway/commands/slidecommands.cpp @@ -0,0 +1,582 @@ +#include "slidecommands.h" +#include "../allowlist.h" + +// Slide command family, implemented in the sequential order set by +// cdp-gateway-cli-plan.md §4 ("Slide (third)"). Each command's test cases live in +// gateway-test-case-designs.md under the matching §D heading (this file: §D1). +// +// Script bodies were written against sdkjs/slide/apiBuilder.js as it actually reads -- +// not guessed. Every %%SCOPE%% is the sole substitution point, filled by +// GatewayCommandRunner via QJsonDocument(scope).toJson(Compact), never per-field +// string interpolation. + +namespace Gateway::Commands +{ + void RegisterSlideCommands() + { + auto& table = AllowlistTable::Instance(); + + // --- D1. Slide management --- + // + // AddSlide(oSlide, nIndex) (apiBuilder.js:1365) needs an actual ApiSlide via + // Api.CreateSlide() (805), not a bare index -- an out-of-range nIndex is + // silently treated as append-at-end, not an error. RemoveSlides(nStart, + // nCount) (1564) takes a contiguous start+count range, not an arbitrary + // indices array, and returns false (not a throw) for an out-of-range nStart -- + // converted to a thrown error here. Duplicate/MoveTo (3853,3872) are called on + // a resolved ApiSlide (GetSlideByIndex, 1324), not ApiPresentation directly. + + auto resolveSlide = QStringLiteral(R"js( + var presentation = Api.GetPresentation(); + var slide = presentation.GetSlideByIndex(scope.index); + if (!slide) throw new Error("no slide at index " + scope.index); + )js"); + + table.Register(QStringLiteral("slide.addSlide"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireInt(scope, QStringLiteral("index")); + }, + QStringLiteral(R"js( + (function(scope){ + var newSlide = Api.CreateSlide(); + Api.GetPresentation().AddSlide(newSlide, scope.index); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("slide.removeSlides"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("start")); + if (!err.isEmpty()) return err; + return RequireInt(scope, QStringLiteral("count"), /*minimum=*/1); + }, + QStringLiteral(R"js( + (function(scope){ + var removed = Api.GetPresentation().RemoveSlides(scope.start, scope.count); + if (!removed) throw new Error("RemoveSlides failed (start/count out of range)"); + return true; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("slide.duplicate"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireInt(scope, QStringLiteral("index")); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveSlide + QStringLiteral(R"js( + var duplicate = slide.Duplicate(); + return !!duplicate; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("slide.moveTo"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("index")); + if (!err.isEmpty()) return err; + return RequireInt(scope, QStringLiteral("newIndex")); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveSlide + QStringLiteral(R"js( + return slide.MoveTo(scope.newIndex); + })(%%SCOPE%%); + )js") + }); + + // --- D2. Enumerate slide content --- + // + // ApiSlide.GetAllShapes/GetAllImages/GetAllTables/GetAllCharts + // (apiBuilder.js:4140,4155,4197,4169) return Api*[], not JSON-serializable -- + // returned as index arrays, same established pattern as word.getAllTables (§B2). + + table.Register(QStringLiteral("slide.getAllShapes"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireInt(scope, QStringLiteral("index")); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveSlide + QStringLiteral(R"js( + return slide.GetAllShapes().map(function(_, idx){ return idx; }); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("slide.getAllImages"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireInt(scope, QStringLiteral("index")); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveSlide + QStringLiteral(R"js( + return slide.GetAllImages().map(function(_, idx){ return idx; }); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("slide.getAllTables"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireInt(scope, QStringLiteral("index")); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveSlide + QStringLiteral(R"js( + return slide.GetAllTables().map(function(_, idx){ return idx; }); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("slide.getAllCharts"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireInt(scope, QStringLiteral("index")); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveSlide + QStringLiteral(R"js( + return slide.GetAllCharts().map(function(_, idx){ return idx; }); + })(%%SCOPE%%); + )js") + }); + + // --- D3. Apply layouts, masters, themes (theme application deferred) --- + // + // GetLayout/ApplyLayout (apiBuilder.js:4092,3800) work with real ApiLayout + // objects -- no id-string addressing exists. applyLayout borrows an + // already-resolved layout from another slide rather than looking one up by a + // fabricated layoutId. AddMaster (1522) needs a real ApiMaster from + // Api.CreateMaster() (553), called with no theme argument to use its own + // documented fallback. slide.applyTheme is deliberately NOT implemented -- + // Api.CreateTheme (640) needs three further factory-built scheme objects not + // confirmed in this pass; see gateway-test-case-designs.md §D3. + + table.Register(QStringLiteral("slide.getLayout"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireInt(scope, QStringLiteral("index")); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveSlide + QStringLiteral(R"js( + return !!slide.GetLayout(); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("slide.applyLayout"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("index")); + if (!err.isEmpty()) return err; + return RequireInt(scope, QStringLiteral("fromIndex")); + }, + QStringLiteral(R"js( + (function(scope){ + var presentation = Api.GetPresentation(); + var slide = presentation.GetSlideByIndex(scope.index); + if (!slide) throw new Error("no slide at index " + scope.index); + var fromSlide = presentation.GetSlideByIndex(scope.fromIndex); + if (!fromSlide) throw new Error("no slide at fromIndex " + scope.fromIndex); + var layout = fromSlide.GetLayout(); + if (!layout) throw new Error("slide at fromIndex has no layout to borrow"); + return slide.ApplyLayout(layout); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("slide.addMaster"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireInt(scope, QStringLiteral("position")); + }, + QStringLiteral(R"js( + (function(scope){ + var master = Api.CreateMaster(); + if (!master) throw new Error("CreateMaster failed (no theme available)"); + return Api.GetPresentation().AddMaster(scope.position, master); + })(%%SCOPE%%); + )js") + }); + + // --- D4. Set transitions (background deferred) --- + // + // Api.CreateSlideShowTransition() (apiBuilder.js:1075, no-arg) configured via + // SetEntryEffect/SetDuration (4848,4902), then ApiSlide.SetSlideShowTransition + // (4389). slide.setBackground intentionally NOT implemented -- SetBackground + // (3723) needs a real ApiFill; no solid-fill factory was confirmed in this + // file. See gateway-test-case-designs.md §D4. + + // --- D5. Insert shapes/text boxes with positioning --- + // + // Api.CreateShape(sType, nWidth, nHeight) (apiBuilder.js:870) has real + // internal defaults for fill/stroke -- fully implementable, unlike Cell's + // AddShape (§C11). Positioned separately via AddObject (3621) + + // ApiDrawing.SetPosition (6100), since CreateShape itself takes no position. + // Existing shapes resolved via slide.GetAllShapes()[shapeIndex], same index + // space as slide.getAllShapes (§D2). + + auto resolveShape = QStringLiteral(R"js( + var presentation = Api.GetPresentation(); + var slide = presentation.GetSlideByIndex(scope.index); + if (!slide) throw new Error("no slide at index " + scope.index); + var shape = slide.GetAllShapes()[scope.shapeIndex]; + if (!shape) throw new Error("no shape at shapeIndex " + scope.shapeIndex); + )js"); + + table.Register(QStringLiteral("slide.createShape"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("index")); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("type"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + for (const char* field : {"x", "y", "width", "height"}) + { + err = RequireInt(scope, QString::fromLatin1(field)); + if (!err.isEmpty()) return err; + } + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveSlide + QStringLiteral(R"js( + var shape = Api.CreateShape(scope.type, scope.width, scope.height); + var added = slide.AddObject(shape); + if (!added) throw new Error("AddObject failed"); + shape.SetPosition(scope.x, scope.y); + return true; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("slide.setPosition"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("index")); + if (!err.isEmpty()) return err; + err = RequireInt(scope, QStringLiteral("shapeIndex")); + if (!err.isEmpty()) return err; + err = RequireInt(scope, QStringLiteral("x")); + if (!err.isEmpty()) return err; + return RequireInt(scope, QStringLiteral("y")); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveShape + QStringLiteral(R"js( + shape.SetPosition(scope.x, scope.y); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("slide.setRotation"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("index")); + if (!err.isEmpty()) return err; + err = RequireInt(scope, QStringLiteral("shapeIndex")); + if (!err.isEmpty()) return err; + return RequireInt(scope, QStringLiteral("degrees")); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveShape + QStringLiteral(R"js( + return shape.SetRotation(scope.degrees); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("slide.setSize"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("index")); + if (!err.isEmpty()) return err; + err = RequireInt(scope, QStringLiteral("shapeIndex")); + if (!err.isEmpty()) return err; + err = RequireInt(scope, QStringLiteral("width"), /*minimum=*/0); + if (!err.isEmpty()) return err; + return RequireInt(scope, QStringLiteral("height"), /*minimum=*/0); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveShape + QStringLiteral(R"js( + shape.SetSize(scope.width, scope.height); + return null; + })(%%SCOPE%%); + )js") + }); + + // --- D6. Text formatting --- + // + // ApiRun.SetBold/SetFontFamily are shared classes with Word (confirmed absent + // from this file, so they come from a common file included by all editors), + // per the plan's own note. Resolved via ApiShape.GetContent() (6975) + + // GetElement(paraIndex).GetElement(runIndex), same chain as word.setBold + // (§B4) -- the originally-planned scope was missing paraIndex, added here. + + auto resolveSlideRun = QStringLiteral(R"js( + var presentation = Api.GetPresentation(); + var slide = presentation.GetSlideByIndex(scope.index); + if (!slide) throw new Error("no slide at index " + scope.index); + var shape = slide.GetAllShapes()[scope.shapeIndex]; + if (!shape) throw new Error("no shape at shapeIndex " + scope.shapeIndex); + var para = shape.GetContent().GetElement(scope.paraIndex); + if (!para) throw new Error("no paragraph at paraIndex " + scope.paraIndex); + var run = para.GetElement(scope.runIndex); + if (!run) throw new Error("no run at runIndex " + scope.runIndex); + )js"); + + table.Register(QStringLiteral("slide.setBold"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + for (const char* field : {"index", "shapeIndex", "paraIndex", "runIndex"}) + { + const QString err = RequireInt(scope, QString::fromLatin1(field)); + if (!err.isEmpty()) return err; + } + return RequireBool(scope, QStringLiteral("bold")); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveSlideRun + QStringLiteral(R"js( + run.SetBold(scope.bold); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("slide.setFontFamily"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + for (const char* field : {"index", "shapeIndex", "paraIndex", "runIndex"}) + { + const QString err = RequireInt(scope, QString::fromLatin1(field)); + if (!err.isEmpty()) return err; + } + return RequireString(scope, QStringLiteral("font"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveSlideRun + QStringLiteral(R"js( + run.SetFontFamily(scope.font); + return null; + })(%%SCOPE%%); + )js") + }); + + // --- D7. Insert images --- + // + // Api.CreateImage(sImageSrc, nWidth, nHeight) (apiBuilder.js:825) -- URL/base64 + // data URI, matching the Word/Cell precedent; same two-step + // AddObject+SetPosition pattern as slide.createShape (§D5). + + table.Register(QStringLiteral("slide.createImage"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("index")); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("imageSrc"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + for (const char* field : {"x", "y", "width", "height"}) + { + err = RequireInt(scope, QString::fromLatin1(field)); + if (!err.isEmpty()) return err; + } + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveSlide + QStringLiteral(R"js( + var image = Api.CreateImage(scope.imageSrc, scope.width, scope.height); + var added = slide.AddObject(image); + if (!added) throw new Error("AddObject failed"); + image.SetPosition(scope.x, scope.y); + return true; + })(%%SCOPE%%); + )js") + }); + + // --- D8. Table editing (creation deferred) --- + // + // Api.CreateTable (apiBuilder.js:947) places the table on whatever + // private_GetCurrentSlide() resolves to -- no public setter exists to target + // an arbitrary slide by index first, so slide.createTable is deliberately NOT + // implemented. AddRow/MergeCells (7412, 7314) operate on an existing table, + // addressed via slide.GetAllTables()[tableIndex] (§D2). Cell resolution via + // GetRow(r).GetCell(c) (7295, 7614) -- no GetCell(row,col) shortcut exists here. + + auto resolveSlideTable = QStringLiteral(R"js( + var presentation = Api.GetPresentation(); + var slide = presentation.GetSlideByIndex(scope.index); + if (!slide) throw new Error("no slide at index " + scope.index); + var table = slide.GetAllTables()[scope.tableIndex]; + if (!table) throw new Error("no table at tableIndex " + scope.tableIndex); + )js"); + + table.Register(QStringLiteral("slide.addRow"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("index")); + if (!err.isEmpty()) return err; + return RequireInt(scope, QStringLiteral("tableIndex")); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveSlideTable + QStringLiteral(R"js( + var row = table.AddRow(); + return !!row; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("slide.mergeCells"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("index")); + if (!err.isEmpty()) return err; + for (const char* field : {"tableIndex", "fromRow", "fromCol", "toRow", "toCol"}) + { + err = RequireInt(scope, QString::fromLatin1(field)); + if (!err.isEmpty()) return err; + } + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveSlideTable + QStringLiteral(R"js( + var cells = []; + for (var r = scope.fromRow; r <= scope.toRow; r++) { + var row = table.GetRow(r); + if (!row) continue; + for (var c = scope.fromCol; c <= scope.toCol; c++) { + var cell = row.GetCell(c); + if (cell) cells.push(cell); + } + } + var merged = table.MergeCells(cells); + if (!merged) throw new Error("merge failed for the given range"); + return null; + })(%%SCOPE%%); + )js") + }); + + // --- D9. Speaker notes --- + // + // ApiSlide.AddNotesText(sText) (apiBuilder.js:4331) calls ApiParagraph.AddText + // internally -- same append-only semantics as word.addText (§B3). Read-back + // (slide.getNotesText, not in the original design) via + // GetNotesPage().GetBodyShape().GetDocContent().GetElement(0).GetText() -- + // without it there was no way to verify addNotesText's effect at all. + + table.Register(QStringLiteral("slide.addNotesText"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("index")); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("text"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveSlide + QStringLiteral(R"js( + return slide.AddNotesText(scope.text); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("slide.getNotesText"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireInt(scope, QStringLiteral("index")); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveSlide + QStringLiteral(R"js( + var notesPage = slide.GetNotesPage(); + if (!notesPage) return ""; + var bodyShape = notesPage.GetBodyShape(); + if (!bodyShape) return ""; + var docContent = bodyShape.GetDocContent(); + if (!docContent) return ""; + var para = docContent.GetElement(0); + if (!para) return ""; + return para.GetText(); + })(%%SCOPE%%); + )js") + }); + + // --- D10. Comments --- + // + // ApiSlide.AddComment(posX, posY, text, author, userId) (apiBuilder.js:3649) + // takes an EMU position -- x/y added to the scope, missing from the original + // design. ApiPresentation.GetAllComments() (1697) returns ApiComment[] via the + // same shared class as Word (GetText/GetAuthorName, §B13) -- returned as plain + // {text,author} objects, same pattern as word.getAllComments. + + table.Register(QStringLiteral("slide.addComment"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("index")); + if (!err.isEmpty()) return err; + err = RequireInt(scope, QStringLiteral("x")); + if (!err.isEmpty()) return err; + err = RequireInt(scope, QStringLiteral("y")); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("text"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("author"), /*allowEmpty=*/true); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveSlide + QStringLiteral(R"js( + return slide.AddComment(scope.x, scope.y, scope.text, scope.author); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("presentation.getAllComments"), CommandSpec{ + [](const QJsonObject&) -> QString { return QString(); }, + QStringLiteral(R"js( + (function(scope){ + return Api.GetPresentation().GetAllComments().map(function(c){ + return {text: c.GetText(), author: c.GetAuthorName()}; + }); + })(%%SCOPE%%); + )js") + }); + + // --- D11. Document properties --- + // + // ApiPresentation.GetDocumentInfo() (apiBuilder.js:1849) returns a plain JS + // object of primitives -- already JSON-safe. GetCustomProperties() (1936) + // returns the same shared ApiCustomProperties class as Word (§B1, no GetAll) + // -- corrected to a singular getCustomProperty{name}, same fix as + // word.getCustomProperty. + + table.Register(QStringLiteral("presentation.getDocumentInfo"), CommandSpec{ + [](const QJsonObject&) -> QString { return QString(); }, + QStringLiteral(R"js( + (function(scope){ + return Api.GetPresentation().GetDocumentInfo(); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("presentation.getCustomProperty"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireString(scope, QStringLiteral("name"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + return Api.GetPresentation().GetCustomProperties().Get(scope.name); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("slide.setTransition"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("index")); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("entryEffect"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireInt(scope, QStringLiteral("duration"), /*minimum=*/0); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveSlide + QStringLiteral(R"js( + var transition = Api.CreateSlideShowTransition(); + if (!transition.SetEntryEffect(scope.entryEffect)) + throw new Error("unrecognized entryEffect: " + scope.entryEffect); + transition.SetDuration(scope.duration); + return slide.SetSlideShowTransition(transition); + })(%%SCOPE%%); + )js") + }); + } +} diff --git a/win-linux/src/gateway/commands/slidecommands.h b/win-linux/src/gateway/commands/slidecommands.h new file mode 100644 index 000000000..ec9120ff6 --- /dev/null +++ b/win-linux/src/gateway/commands/slidecommands.h @@ -0,0 +1,11 @@ +#ifndef GATEWAY_COMMANDS_SLIDECOMMANDS_H +#define GATEWAY_COMMANDS_SLIDECOMMANDS_H + +namespace Gateway::Commands +{ + // Registers this family's commands into AllowlistTable::Instance(). See + // wordcommands.h for the registration-call convention. + void RegisterSlideCommands(); +} + +#endif // GATEWAY_COMMANDS_SLIDECOMMANDS_H diff --git a/win-linux/src/gateway/commands/wordcommands.cpp b/win-linux/src/gateway/commands/wordcommands.cpp new file mode 100644 index 000000000..fed0174d8 --- /dev/null +++ b/win-linux/src/gateway/commands/wordcommands.cpp @@ -0,0 +1,847 @@ +#include "wordcommands.h" +#include "../allowlist.h" + +#include + +// Word command family, implemented in the sequential order set by +// cdp-gateway-cli-plan.md §4 ("Word (first)"). Each command's test cases live in +// gateway-test-case-designs.md under the matching §B heading (this file: §B1). +// +// Script bodies were written against sdkjs/word/apiBuilder.js as it actually reads +// (ApiCore.SetTitle/GetTitle, ApiCustomProperties.Add/Get) — not guessed. Every +// %%SCOPE%% is the sole substitution point, filled by GatewayCommandRunner via +// QJsonDocument(scope).toJson(Compact), never per-field string interpolation. + +namespace Gateway::Commands +{ + void RegisterWordCommands() + { + auto& table = AllowlistTable::Instance(); + + // --- B1. Document properties --- + + table.Register(QStringLiteral("word.getTitle"), CommandSpec{ + [](const QJsonObject&) -> QString { return QString(); }, // no scope fields required + QStringLiteral(R"js( + (function(scope){ + return Api.GetDocument().GetCore().GetTitle(); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.setTitle"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireString(scope, QStringLiteral("title"), /*allowEmpty=*/true); + }, + QStringLiteral(R"js( + (function(scope){ + Api.GetDocument().GetCore().SetTitle(scope.title); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.setCustomProperty"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("name"), /*allowEmpty=*/false); + if (!err.isEmpty()) + return err; + if (!scope.contains(QStringLiteral("value")) || + !(scope.value(QStringLiteral("value")).isString() || + scope.value(QStringLiteral("value")).isDouble() || + scope.value(QStringLiteral("value")).isBool())) + return QStringLiteral("scope.value must be a string, number, or boolean"); + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + var ok = Api.GetDocument().GetCustomProperties().Add(scope.name, scope.value); + if (!ok) throw new Error("unsupported custom property value type"); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.getCustomProperty"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireString(scope, QStringLiteral("name"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + return Api.GetDocument().GetCustomProperties().Get(scope.name); + })(%%SCOPE%%); + )js") + }); + + // --- B2. Content enumeration --- + // + // ApiDocument inherits ApiDocumentContent (apiBuilder.js:3112), so + // GetAllParagraphs/GetAllTables/GetAllDrawingObjects/GetAllCharts are real, + // inherited methods (apiBuilder.js:6193-6289). Each returns an array of Api* + // object instances, which aren't themselves JSON-serializable over CDP's + // returnByValue -- returned as an array of 0-based indices instead, matching + // gateway-test-case-designs.md §B2's corrected expectation and the same index + // space paraIndex/tableIndex/etc. scope fields elsewhere already use. + + table.Register(QStringLiteral("word.getAllParagraphs"), CommandSpec{ + [](const QJsonObject&) -> QString { return QString(); }, + QStringLiteral(R"js( + (function(scope){ + return Api.GetDocument().GetAllParagraphs().map(function(_, idx){ return idx; }); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.getAllTables"), CommandSpec{ + [](const QJsonObject&) -> QString { return QString(); }, + QStringLiteral(R"js( + (function(scope){ + return Api.GetDocument().GetAllTables().map(function(_, idx){ return idx; }); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.getAllDrawingObjects"), CommandSpec{ + [](const QJsonObject&) -> QString { return QString(); }, + QStringLiteral(R"js( + (function(scope){ + return Api.GetDocument().GetAllDrawingObjects().map(function(_, idx){ return idx; }); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.getAllCharts"), CommandSpec{ + [](const QJsonObject&) -> QString { return QString(); }, + QStringLiteral(R"js( + (function(scope){ + return Api.GetDocument().GetAllCharts().map(function(_, idx){ return idx; }); + })(%%SCOPE%%); + )js") + }); + + // --- B3. Insert/edit text --- + // + // ApiDocumentContent.GetElement(nPos) (apiBuilder.js:6028) resolves a + // paragraph by index; ApiParagraph.GetElement(nPos) (apiBuilder.js:10315) + // resolves a run within it the same way. ApiParagraph.AddText(text) + // (apiBuilder.js:10146) always appends a *new* run carrying `text` -- it does + // not edit an existing run in place -- and ApiRun.GetText (apiBuilder.js:12883) + // reads one run's text back. + + table.Register(QStringLiteral("word.addText"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("paraIndex")); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("text"), /*allowEmpty=*/true); + }, + QStringLiteral(R"js( + (function(scope){ + var para = Api.GetDocument().GetElement(scope.paraIndex); + if (!para) throw new Error("no paragraph at paraIndex " + scope.paraIndex); + para.AddText(scope.text); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.getText"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("paraIndex")); + if (!err.isEmpty()) return err; + return RequireInt(scope, QStringLiteral("runIndex")); + }, + QStringLiteral(R"js( + (function(scope){ + var para = Api.GetDocument().GetElement(scope.paraIndex); + if (!para) throw new Error("no paragraph at paraIndex " + scope.paraIndex); + var run = para.GetElement(scope.runIndex); + if (!run) throw new Error("no run at runIndex " + scope.runIndex); + return run.GetText(); + })(%%SCOPE%%); + )js") + }); + + // --- B4. Character formatting --- + // + // All four are ApiRun methods (apiBuilder.js:12457,12550,12614; SetColor at + // 12507 forwards to ApiTextPr.SetColor at 15918, whose non-ApiColor branch + // still accepts plain (r,g,b) ints -- used here instead of constructing an + // ApiColor object, since the scope's hex-string shape already has to be + // decomposed into r/g/b for that call regardless. + + auto resolveRun = QStringLiteral(R"js( + var para = Api.GetDocument().GetElement(scope.paraIndex); + if (!para) throw new Error("no paragraph at paraIndex " + scope.paraIndex); + var run = para.GetElement(scope.runIndex); + if (!run) throw new Error("no run at runIndex " + scope.runIndex); + )js"); + + table.Register(QStringLiteral("word.setBold"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("paraIndex")); + if (!err.isEmpty()) return err; + err = RequireInt(scope, QStringLiteral("runIndex")); + if (!err.isEmpty()) return err; + return RequireBool(scope, QStringLiteral("bold")); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveRun + QStringLiteral(R"js( + run.SetBold(scope.bold); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.setItalic"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("paraIndex")); + if (!err.isEmpty()) return err; + err = RequireInt(scope, QStringLiteral("runIndex")); + if (!err.isEmpty()) return err; + return RequireBool(scope, QStringLiteral("italic")); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveRun + QStringLiteral(R"js( + run.SetItalic(scope.italic); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.setFontFamily"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("paraIndex")); + if (!err.isEmpty()) return err; + err = RequireInt(scope, QStringLiteral("runIndex")); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("font"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveRun + QStringLiteral(R"js( + run.SetFontFamily(scope.font); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.setColor"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("paraIndex")); + if (!err.isEmpty()) return err; + err = RequireInt(scope, QStringLiteral("runIndex")); + if (!err.isEmpty()) return err; + static const QRegularExpression hexColor(QStringLiteral("^#[0-9A-Fa-f]{6}$")); + const QJsonValue colorValue = scope.value(QStringLiteral("color")); + if (!colorValue.isString() || !hexColor.match(colorValue.toString()).hasMatch()) + return QStringLiteral("scope.color must be a #RRGGBB hex string"); + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveRun + QStringLiteral(R"js( + var hex = scope.color.replace('#', ''); + var r = parseInt(hex.substring(0, 2), 16); + var g = parseInt(hex.substring(2, 4), 16); + var b = parseInt(hex.substring(4, 6), 16); + run.SetColor(r, g, b); + return null; + })(%%SCOPE%%); + )js") + }); + + // --- B5. Paragraph formatting --- + // + // ApiParagraph.GetParaPr() (apiBuilder.js:10253) returns the ApiParaPr; + // SetJc/SetSpacingBefore/SetIndLeft (16677, 16863, 16580) all take twips + // (1/1440 inch) -- scope field named `twips`, not `points`, to match. + + static const QStringList validAlignments = { + QStringLiteral("left"), QStringLiteral("right"), + QStringLiteral("center"), QStringLiteral("both") + }; + + table.Register(QStringLiteral("word.setJc"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("paraIndex")); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("align"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + if (!validAlignments.contains(scope.value(QStringLiteral("align")).toString())) + return QStringLiteral("scope.align must be one of left, right, center, both"); + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + var para = Api.GetDocument().GetElement(scope.paraIndex); + if (!para) throw new Error("no paragraph at paraIndex " + scope.paraIndex); + para.GetParaPr().SetJc(scope.align); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.setSpacingBefore"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("paraIndex")); + if (!err.isEmpty()) return err; + if (!scope.contains(QStringLiteral("twips")) || !scope.value(QStringLiteral("twips")).isDouble()) + return QStringLiteral("scope.twips must be an integer"); + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + var para = Api.GetDocument().GetElement(scope.paraIndex); + if (!para) throw new Error("no paragraph at paraIndex " + scope.paraIndex); + para.GetParaPr().SetSpacingBefore(scope.twips); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.setIndLeft"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("paraIndex")); + if (!err.isEmpty()) return err; + // Negative twips is valid (hanging indent) -- only type-check, no + // minimum, unlike RequireInt's default floor of 0. + if (!scope.contains(QStringLiteral("twips")) || !scope.value(QStringLiteral("twips")).isDouble()) + return QStringLiteral("scope.twips must be an integer"); + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + var para = Api.GetDocument().GetElement(scope.paraIndex); + if (!para) throw new Error("no paragraph at paraIndex " + scope.paraIndex); + para.GetParaPr().SetIndLeft(scope.twips); + return null; + })(%%SCOPE%%); + )js") + }); + + // --- B6. Search & replace --- + // + // ApiDocument.Search (apiBuilder.js:8253) returns an array of ApiRange + // objects, not JSON-serializable over returnByValue (same issue as §B2) -- + // returns the match count instead. ApiDocument.SearchAndReplace + // (apiBuilder.js:7598) takes {searchString, replaceString, matchCase=true}. + + table.Register(QStringLiteral("word.search"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireString(scope, QStringLiteral("text"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + return Api.GetDocument().Search(scope.text, false).length; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.searchAndReplace"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("find"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("replace"), /*allowEmpty=*/true); + }, + QStringLiteral(R"js( + (function(scope){ + return Api.GetDocument().SearchAndReplace({ + searchString: scope.find, + replaceString: scope.replace + }); + })(%%SCOPE%%); + )js") + }); + + // --- B7. Table creation and editing --- + // + // tableIndex indexes into GetAllTables() (apiBuilder.js:6275), the same index + // space word.getAllTables (§B2) already exposes -- not raw document-content + // position, which would also count paragraphs. ApiTable.AddRow/AddColumn + // (13755, 13811) take a *cell* to insert relative to, not a row/column number + // directly -- resolved via ApiTable.GetCell(row, col) (13589) then inserted + // after it (isBefore=false), which is what "insert after rowIndex/colIndex" + // means here. MergeCells (13609) takes an array of ApiTableCell -- built from + // the fromRow/fromCol..toRow/toCol rectangle. SetStyle (13666) takes an + // ApiStyle object, not a style id string -- resolved via + // ApiDocument.GetStyle(sStyleName) (apiBuilder.js:7091). + + auto resolveTable = QStringLiteral(R"js( + var table = Api.GetDocument().GetAllTables()[scope.tableIndex]; + if (!table) throw new Error("no table at tableIndex " + scope.tableIndex); + )js"); + + table.Register(QStringLiteral("word.addRow"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("tableIndex")); + if (!err.isEmpty()) return err; + return RequireInt(scope, QStringLiteral("rowIndex")); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveTable + QStringLiteral(R"js( + var cell = table.GetCell(scope.rowIndex, 0); + if (!cell) throw new Error("no row at rowIndex " + scope.rowIndex); + table.AddRow(cell, false); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.addColumn"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("tableIndex")); + if (!err.isEmpty()) return err; + return RequireInt(scope, QStringLiteral("colIndex")); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveTable + QStringLiteral(R"js( + var cell = table.GetCell(0, scope.colIndex); + if (!cell) throw new Error("no column at colIndex " + scope.colIndex); + table.AddColumn(cell, false); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.mergeCells"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + for (const char* field : {"tableIndex", "fromRow", "fromCol", "toRow", "toCol"}) + { + const QString err = RequireInt(scope, QString::fromLatin1(field)); + if (!err.isEmpty()) return err; + } + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveTable + QStringLiteral(R"js( + var cells = []; + for (var r = scope.fromRow; r <= scope.toRow; r++) { + for (var c = scope.fromCol; c <= scope.toCol; c++) { + var cell = table.GetCell(r, c); + if (cell) cells.push(cell); + } + } + var merged = table.MergeCells(cells); + if (!merged) throw new Error("merge failed for the given range"); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.setStyle"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("tableIndex")); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("styleId"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + )js") + resolveTable + QStringLiteral(R"js( + var style = Api.GetDocument().GetStyle(scope.styleId); + if (!style) throw new Error("no such style " + scope.styleId); + if (!table.SetStyle(style)) throw new Error("SetStyle failed"); + return null; + })(%%SCOPE%%); + )js") + }); + + // --- B8. Style creation and application --- + // + // ApiDocument.CreateStyle/GetStyle (apiBuilder.js:7106,7091); + // ApiStyle.SetTextPr (15424) requires an actual ApiTextPr, built via + // Api.CreateTextPr() (27443). getStyle returns a boolean (ApiStyle.Style is + // undefined when the named style doesn't exist) rather than the unserializable + // ApiStyle handle -- same pattern as §B2/§B6. + + static const QStringList validStyleTypes = { + QStringLiteral("paragraph"), QStringLiteral("table"), + QStringLiteral("run"), QStringLiteral("numbering") + }; + + table.Register(QStringLiteral("word.createStyle"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("name"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("type"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + if (!validStyleTypes.contains(scope.value(QStringLiteral("type")).toString())) + return QStringLiteral("scope.type must be one of paragraph, table, run, numbering"); + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + Api.GetDocument().CreateStyle(scope.name, scope.type); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.getStyle"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireString(scope, QStringLiteral("name"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + var style = Api.GetDocument().GetStyle(scope.name); + return !!(style && style.Style); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.setStyleTextPr"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("styleId"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireBool(scope, QStringLiteral("bold")); + }, + QStringLiteral(R"js( + (function(scope){ + var style = Api.GetDocument().GetStyle(scope.styleId); + if (!style || !style.Style) throw new Error("no such style " + scope.styleId); + var textPr = Api.CreateTextPr(); + textPr.SetBold(scope.bold); + style.SetTextPr(textPr); + return null; + })(%%SCOPE%%); + )js") + }); + + // --- B9. Insert images/shapes with positioning --- + // + // Api.CreateImage(imageSrc, width, height) (apiBuilder.js:4674) -- imageSrc is + // a URL or base64 data URI, not a local file path (per the method's own doc + // comment); width/height are EMU. ApiParagraph.AddDrawing (10486) inserts the + // created ApiImage (extends ApiDrawing, 3665) into a paragraph. + // SetWrappingStyle/SetHorPosition (18651, 18771) operate on a drawing resolved + // the same way word.getAllDrawingObjects (§B2) indexes them. + + static const QStringList validWrappingStyles = { + QStringLiteral("inline"), QStringLiteral("square"), QStringLiteral("tight"), + QStringLiteral("through"), QStringLiteral("topAndBottom"), + QStringLiteral("behind"), QStringLiteral("inFront") + }; + static const QStringList validRelativeFromH = { + QStringLiteral("character"), QStringLiteral("column"), QStringLiteral("leftMargin"), + QStringLiteral("rightMargin"), QStringLiteral("margin"), QStringLiteral("page") + }; + + table.Register(QStringLiteral("word.createImage"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("paraIndex")); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("imageSrc"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + err = RequireInt(scope, QStringLiteral("width"), /*minimum=*/1); + if (!err.isEmpty()) return err; + return RequireInt(scope, QStringLiteral("height"), /*minimum=*/1); + }, + QStringLiteral(R"js( + (function(scope){ + var para = Api.GetDocument().GetElement(scope.paraIndex); + if (!para) throw new Error("no paragraph at paraIndex " + scope.paraIndex); + var image = Api.CreateImage(scope.imageSrc, scope.width, scope.height); + para.AddDrawing(image); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.setWrappingStyle"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("drawingIndex")); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("style"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + if (!validWrappingStyles.contains(scope.value(QStringLiteral("style")).toString())) + return QStringLiteral("scope.style is not a recognized wrapping style"); + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + var drawing = Api.GetDocument().GetAllDrawingObjects()[scope.drawingIndex]; + if (!drawing) throw new Error("no drawing at drawingIndex " + scope.drawingIndex); + return drawing.SetWrappingStyle(scope.style); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.setHorPosition"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("drawingIndex")); + if (!err.isEmpty()) return err; + err = RequireInt(scope, QStringLiteral("distanceEmu")); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("relativeTo"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + if (!validRelativeFromH.contains(scope.value(QStringLiteral("relativeTo")).toString())) + return QStringLiteral("scope.relativeTo is not a recognized reference point"); + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + var drawing = Api.GetDocument().GetAllDrawingObjects()[scope.drawingIndex]; + if (!drawing) throw new Error("no drawing at drawingIndex " + scope.drawingIndex); + return drawing.SetHorPosition(scope.relativeTo, scope.distanceEmu, false); + })(%%SCOPE%%); + )js") + }); + + // --- B10. Headers/footers, page setup --- + // + // ApiDocument has no GetSection(index), only GetFinalSection() + // (apiBuilder.js:7191) -- these operate on the document's one section, correct + // for every fixture used across this file. ApiSection.GetHeader (13289) + // returns an ApiDocumentContent with no direct text-insertion method; + // populated via Api.CreateParagraph() (4575) + ApiParagraph.AddText (§B3) + + // ApiDocumentContent.AddElement(0, paragraph) (6052). SetPageMargins/ + // SetPageSize (13181, 13141) take twips. + + static const QStringList validHeaderTypes = { + QStringLiteral("title"), QStringLiteral("even"), QStringLiteral("default") + }; + + table.Register(QStringLiteral("word.setHeaderText"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireString(scope, QStringLiteral("type"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + if (!validHeaderTypes.contains(scope.value(QStringLiteral("type")).toString())) + return QStringLiteral("scope.type must be one of title, even, default"); + return RequireString(scope, QStringLiteral("text"), /*allowEmpty=*/true); + }, + QStringLiteral(R"js( + (function(scope){ + var section = Api.GetDocument().GetFinalSection(); + var header = section.GetHeader(scope.type, true); + if (!header) throw new Error("could not get/create header of type " + scope.type); + var para = Api.CreateParagraph(); + para.AddText(scope.text); + header.AddElement(0, para); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.setPageMargins"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + for (const char* field : {"left", "top", "right", "bottom"}) + { + const QString err = RequireInt(scope, QString::fromLatin1(field)); + if (!err.isEmpty()) return err; + } + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + return Api.GetDocument().GetFinalSection().SetPageMargins(scope.left, scope.top, scope.right, scope.bottom); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.setPageSize"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("width"), /*minimum=*/1); + if (!err.isEmpty()) return err; + return RequireInt(scope, QStringLiteral("height"), /*minimum=*/1); + }, + QStringLiteral(R"js( + (function(scope){ + return Api.GetDocument().GetFinalSection().SetPageSize(scope.width, scope.height, true); + })(%%SCOPE%%); + )js") + }); + + // --- B11. Bookmarks and hyperlinks --- + // + // Bookmark creation is only on ApiRange (apiBuilder.js:1581), not + // ApiParagraph directly -- resolved via ApiParagraph.GetRange(0,0) (10604). + // ApiParagraph.AddHyperlink(sLink, sScreenTipText, sBookmarkName) + // (apiBuilder.js:10578) takes no display-text parameter -- it wraps whatever + // text is already in the paragraph (via an internal SelectAll), so the text is + // added first via AddText (§B3), then wrapped. getBookmark returns a boolean, + // matching the unserializable-handle pattern already used in §B6/§B8. The URL + // scheme allowlist below is defense-in-depth at the gateway's own schema layer, + // independent of whatever scheme handling AddHyperlink does internally. + + static const QRegularExpression allowedUrlScheme( + QStringLiteral("^(https?://|mailto:)"), QRegularExpression::CaseInsensitiveOption); + + table.Register(QStringLiteral("word.addBookmark"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("paraIndex")); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("name"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + var para = Api.GetDocument().GetElement(scope.paraIndex); + if (!para) throw new Error("no paragraph at paraIndex " + scope.paraIndex); + var range = para.GetRange(0, 0); + if (!range) throw new Error("could not get a range for paraIndex " + scope.paraIndex); + range.AddBookmark(scope.name); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.getBookmark"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireString(scope, QStringLiteral("name"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + return !!Api.GetDocument().GetBookmark(scope.name); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.addHyperlink"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("paraIndex")); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("text"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + const QJsonValue urlValue = scope.value(QStringLiteral("url")); + if (!urlValue.isString() || !allowedUrlScheme.match(urlValue.toString()).hasMatch()) + return QStringLiteral("scope.url must start with http://, https://, or mailto:"); + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + var para = Api.GetDocument().GetElement(scope.paraIndex); + if (!para) throw new Error("no paragraph at paraIndex " + scope.paraIndex); + para.AddText(scope.text); + var link = para.AddHyperlink(scope.url, "", null); + if (!link) throw new Error("AddHyperlink rejected the given url/text"); + return null; + })(%%SCOPE%%); + )js") + }); + + // --- B12. Fillable form fields / content controls --- + // + // ApiDocument.InsertTextForm (sdkjs-forms/apiBuilder.js:452) inserts at the + // current cursor/selection -- positioned via ApiRange.Select() + // (sdkjs/word/apiBuilder.js:1737) on paragraph.GetRange(0,0) first. + // GetAllForms/SetFormsData confirmed (apiBuilder.js:8613,7963); SetFormsData + // takes Array<{key,value}>. word.addCheckBoxForm is deliberately NOT + // implemented here -- no confirmed insertion method exists in the vendored + // source for a created ApiCheckBoxForm; see gateway-test-case-designs.md §B12 + // for the full reasoning. Do not add it without finding/confirming a real + // insertion path first. + + table.Register(QStringLiteral("word.addTextForm"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("paraIndex")); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("key"), /*allowEmpty=*/false); + }, + QStringLiteral(R"js( + (function(scope){ + var para = Api.GetDocument().GetElement(scope.paraIndex); + if (!para) throw new Error("no paragraph at paraIndex " + scope.paraIndex); + var range = para.GetRange(0, 0); + if (!range) throw new Error("could not get a range for paraIndex " + scope.paraIndex); + range.Select(); + var form = Api.GetDocument().InsertTextForm({key: scope.key}); + if (!form) throw new Error("InsertTextForm failed"); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.getAllForms"), CommandSpec{ + [](const QJsonObject&) -> QString { return QString(); }, + QStringLiteral(R"js( + (function(scope){ + return Api.GetDocument().GetAllForms().map(function(form){ return form.GetFormKey(); }); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.setFormsData"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + if (!scope.contains(QStringLiteral("data")) || !scope.value(QStringLiteral("data")).isObject()) + return QStringLiteral("scope.data must be an object of {key: value}"); + return QString(); + }, + QStringLiteral(R"js( + (function(scope){ + var arrData = Object.keys(scope.data).map(function(key){ + return {key: key, value: scope.data[key]}; + }); + return Api.GetDocument().SetFormsData(arrData); + })(%%SCOPE%%); + )js") + }); + + // --- B13. Comments and track changes --- + // + // ApiDocument.AddComment(sText, sAuthor, sUserId) (apiBuilder.js:9583) adds + // "to the current document selection, or to the current word if no text is + // selected" -- positioned via the same GetRange(0,0).Select() pattern as §B12. + // GetAllComments (8702) returns ApiComment[], mapped to plain {text, author} + // objects via ApiComment.GetText/GetAuthorName (27848, 27877) since ApiComment + // isn't itself JSON-serializable, same pattern as elsewhere in this file. + // SetTrackRevisions/AcceptAllRevisionChanges (7982 confirmed earlier in this + // family's design, 8856) are direct ApiDocument methods, no resolution needed. + + table.Register(QStringLiteral("word.addComment"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + QString err = RequireInt(scope, QStringLiteral("paraIndex")); + if (!err.isEmpty()) return err; + err = RequireString(scope, QStringLiteral("text"), /*allowEmpty=*/false); + if (!err.isEmpty()) return err; + return RequireString(scope, QStringLiteral("author"), /*allowEmpty=*/true); + }, + QStringLiteral(R"js( + (function(scope){ + var para = Api.GetDocument().GetElement(scope.paraIndex); + if (!para) throw new Error("no paragraph at paraIndex " + scope.paraIndex); + var range = para.GetRange(0, 0); + if (!range) throw new Error("could not get a range for paraIndex " + scope.paraIndex); + range.Select(); + var comment = Api.GetDocument().AddComment(scope.text, scope.author); + if (!comment) throw new Error("AddComment failed"); + return null; + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.getAllComments"), CommandSpec{ + [](const QJsonObject&) -> QString { return QString(); }, + QStringLiteral(R"js( + (function(scope){ + return Api.GetDocument().GetAllComments().map(function(c){ + return {text: c.GetText(), author: c.GetAuthorName()}; + }); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.setTrackRevisions"), CommandSpec{ + [](const QJsonObject& scope) -> QString { + return RequireBool(scope, QStringLiteral("enabled")); + }, + QStringLiteral(R"js( + (function(scope){ + return Api.GetDocument().SetTrackRevisions(scope.enabled); + })(%%SCOPE%%); + )js") + }); + + table.Register(QStringLiteral("word.acceptAllRevisionChanges"), CommandSpec{ + [](const QJsonObject&) -> QString { return QString(); }, + QStringLiteral(R"js( + (function(scope){ + Api.GetDocument().AcceptAllRevisionChanges(); + return null; + })(%%SCOPE%%); + )js") + }); + } +} diff --git a/win-linux/src/gateway/commands/wordcommands.h b/win-linux/src/gateway/commands/wordcommands.h new file mode 100644 index 000000000..799159bd6 --- /dev/null +++ b/win-linux/src/gateway/commands/wordcommands.h @@ -0,0 +1,12 @@ +#ifndef GATEWAY_COMMANDS_WORDCOMMANDS_H +#define GATEWAY_COMMANDS_WORDCOMMANDS_H + +namespace Gateway::Commands +{ + // Registers this family's commands into AllowlistTable::Instance(). Called once + // from GatewayServer startup, alongside RegisterCellCommands/RegisterSlideCommands/ + // RegisterPdfCommands as each family lands per the plan's build order. + void RegisterWordCommands(); +} + +#endif // GATEWAY_COMMANDS_WORDCOMMANDS_H diff --git a/win-linux/src/gateway/gatewaycommandrunner.cpp b/win-linux/src/gateway/gatewaycommandrunner.cpp new file mode 100644 index 000000000..225c7d968 --- /dev/null +++ b/win-linux/src/gateway/gatewaycommandrunner.cpp @@ -0,0 +1,106 @@ +#include "gatewaycommandrunner.h" +#include "allowlist.h" + +#include +#include +#include +#include + +#include "applicationmanager.h" +#include "cefview.h" + +namespace Gateway +{ + GatewayCommandRunner::GatewayCommandRunner(CAscApplicationManager* manager, QObject* parent) + : QObject(parent) + , m_manager(manager) + { + } + + Result GatewayCommandRunner::Execute(const QString& command, const QJsonObject& scope, int targetViewId) + { + const CommandSpec* spec = AllowlistTable::Instance().Find(command); + if (!spec) + return Result::Failure(ErrorCode::NotAllowlisted, QStringLiteral("unknown command: %1").arg(command)); + + const QString validationError = spec->validate(scope); + if (!validationError.isEmpty()) + return Result::Failure(ErrorCode::SchemaInvalid, validationError); + + CCefView* view = m_manager ? m_manager->GetViewById(targetViewId) : nullptr; + if (!view) + return Result::Failure(ErrorCode::TargetNotFound, + QStringLiteral("no open document with view id %1").arg(targetViewId)); + + QString script = spec->script; + script.replace(QStringLiteral("%%SCOPE%%"), + QString::fromUtf8(QJsonDocument(scope).toJson(QJsonDocument::Compact))); + + const int messageId = m_nextMessageId++; + + QJsonObject params; + params.insert(QStringLiteral("expression"), script); + params.insert(QStringLiteral("returnByValue"), true); + params.insert(QStringLiteral("awaitPromise"), false); + + QJsonObject request; + request.insert(QStringLiteral("id"), messageId); + request.insert(QStringLiteral("method"), QStringLiteral("Runtime.evaluate")); + request.insert(QStringLiteral("params"), params); + + const QByteArray requestBytes = QJsonDocument(request).toJson(QJsonDocument::Compact); + + // CCefView::SendGatewayDevToolsMessage's callback fires on the CEF browser + // process UI thread -- which, in this single-process-embedded-CEF app, is the + // same thread pumping the Qt event loop we spin below. A nested QEventLoop is + // therefore the correct way to make this call look synchronous to + // GatewayCommandRunner's own caller, matching every functional test case in + // gateway-test-case-designs.md, which expects Execute() to just return a Result. + QEventLoop loop; + Result result = Result::Failure(ErrorCode::ScriptException, QStringLiteral("CDP call timed out")); + + view->SendGatewayDevToolsMessage(requestBytes.toStdString(), messageId, + [&result, &loop](bool ok, const std::string& jsonResponseOrError) { + if (!ok) + { + result = Result::Failure(ErrorCode::ScriptException, QString::fromStdString(jsonResponseOrError)); + loop.quit(); + return; + } + + const QJsonObject response = QJsonDocument::fromJson( + QByteArray::fromStdString(jsonResponseOrError)).object(); + const QJsonObject cdpResult = response.value(QStringLiteral("result")).toObject(); + const QJsonObject exceptionDetails = cdpResult.value(QStringLiteral("exceptionDetails")).toObject(); + + if (!exceptionDetails.isEmpty()) + { + result = Result::Failure(ErrorCode::ScriptException, + exceptionDetails.value(QStringLiteral("text")).toString()); + } + else + { + const QJsonObject remoteObject = cdpResult.value(QStringLiteral("result")).toObject(); + result = Result::Success(remoteObject.value(QStringLiteral("value"))); + } + loop.quit(); + }); + + QTimer::singleShot(5000, &loop, &QEventLoop::quit); // bounded wait; see test case A6 (no hang on a dead target) + loop.exec(); + + return result; + } + + int GatewayCommandRunner::ResolveViewIdByPath(const QString& path) const + { + if (!m_manager || path.isEmpty()) + return -1; + + QString normalized = QDir::isAbsolutePath(path) ? path : QDir::current().absoluteFilePath(path); + normalized = QDir::cleanPath(normalized); + + CCefView* view = m_manager->GetViewByUrl(normalized.toStdWString()); + return view ? view->GetId() : -1; + } +} diff --git a/win-linux/src/gateway/gatewaycommandrunner.h b/win-linux/src/gateway/gatewaycommandrunner.h new file mode 100644 index 000000000..6cfdf7117 --- /dev/null +++ b/win-linux/src/gateway/gatewaycommandrunner.h @@ -0,0 +1,56 @@ +#ifndef GATEWAY_GATEWAYCOMMANDRUNNER_H +#define GATEWAY_GATEWAYCOMMANDRUNNER_H + +#include +#include +#include + +#include "gatewaytypes.h" + +class CAscApplicationManager; + +namespace Gateway +{ + // The one call both the CLI and the external wire protocol are thin shells around + // (see cdp-gateway-cli-plan.md and gateway-test-case-designs.md's "Scope of this + // document"). Owns: allowlist lookup, scope-schema validation, target resolution + // via the app's existing view map (CAscApplicationManager::GetViewById), and + // driving CDP in-process through CCefView::SendGatewayDevToolsMessage (see + // desktop-sdk/ChromiumBasedEditors/lib/include/cefview.h) -- no external + // --remote-debugging-port, no websocket; we already hold the exact CefBrowser via + // the resolved view, so there is no CDP-target-correlation problem to solve. + class GatewayCommandRunner : public QObject + { + Q_OBJECT + public: + explicit GatewayCommandRunner(CAscApplicationManager* manager, QObject* parent = nullptr); + + // Synchronous from the caller's point of view -- internally drives one + // SendGatewayDevToolsMessage round trip via a nested event loop, matching the + // plan's per-command test design (gateway-test-case-designs.md §A expects + // Execute() to return a Result, not take a callback). `targetViewId` selects + // which open document to run against, via CAscApplicationManager::GetViewById + // -- never CDP target URL/title matching, per plan §0. + Result Execute(const QString& command, const QJsonObject& scope, int targetViewId); + + // Backs the gateway.connect meta-command (gatewayserver.cpp) -- a pure + // resolver, not an "open" operation: matches CAscApplicationManager::GetViewByUrl + // (which itself checks a view's GetUrl()/GetOriginalUrl()/GetUrlAsLocal()) after + // normalizing `path` the same way CAscApplicationManagerWrapper::handleInputCmd + // does before a view's local-file URL is ever set, so an already-open or + // freshly-opened view for this file is actually found. Returns -1 if no view + // matches -- the caller (a client that itself launched + // `DesktopEditors `, relying on SingleApplication to either cold-start or + // forward-and-open-a-new-tab in the already-running instance either way) is + // expected to poll this in a bounded loop rather than this method opening + // anything itself, so gateway.connect stays a single CDP-free, side-effect-free + // call like gateway.listCommands, not a second way to open documents. + int ResolveViewIdByPath(const QString& path) const; + + private: + CAscApplicationManager* m_manager; + int m_nextMessageId = 1; + }; +} + +#endif // GATEWAY_GATEWAYCOMMANDRUNNER_H diff --git a/win-linux/src/gateway/gatewayserver.cpp b/win-linux/src/gateway/gatewayserver.cpp new file mode 100644 index 000000000..3b3c04b56 --- /dev/null +++ b/win-linux/src/gateway/gatewayserver.cpp @@ -0,0 +1,203 @@ +#include "gatewayserver.h" +#include "gatewaycommandrunner.h" +#include "gatewaytypes.h" +#include "allowlist.h" +#include "commands/wordcommands.h" +#include "commands/cellcommands.h" +#include "commands/slidecommands.h" +#include "commands/pdfcommands.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace Gateway +{ + GatewayServer::GatewayServer(CAscApplicationManager* manager, QObject* parent) + : QObject(parent) + , m_runner(new GatewayCommandRunner(manager, this)) + { + } + + QString GatewayServer::SocketPath() + { + QString runtimeDir = QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation); + if (runtimeDir.isEmpty()) + runtimeDir = QDir::tempPath(); + return QStringLiteral("%1/eo-gateway-%2.sock").arg(runtimeDir).arg(getuid()); + } + + QString GatewayServer::TokenPath() + { + QString runtimeDir = QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation); + if (runtimeDir.isEmpty()) + runtimeDir = QDir::tempPath(); + return QStringLiteral("%1/eo-gateway-%2.token").arg(runtimeDir).arg(getuid()); + } + + QString GatewayServer::GenerateAndPersistToken() + { + // QUuid is a convenient source of 128 bits of randomness already linked via + // QtCore — no new dependency for token generation. + const QString token = QUuid::createUuid().toString(QUuid::WithoutBraces); + + const QString path = TokenPath(); + QFile::remove(path); // drop any stale token from a previous run before recreating + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return QString(); + file.write(token.toUtf8()); + file.close(); + + // 0600: readable/writable only by the owning user, matching the auth-token + // decision in cdp-gateway-cli-plan.md §0. + ::chmod(path.toUtf8().constData(), S_IRUSR | S_IWUSR); + + return token; + } + + bool GatewayServer::Start() + { + // Registers each implemented command family into AllowlistTable::Instance(). + // Grows in the plan's build order (Word first) as each family lands -- + // Commands::RegisterCellCommands()/RegisterSlideCommands()/RegisterPdfCommands() + // join this list when those families exist. + Commands::RegisterWordCommands(); + Commands::RegisterCellCommands(); + Commands::RegisterSlideCommands(); + Commands::RegisterPdfCommands(); + + const QString socketPath = SocketPath(); + QLocalServer::removeServer(socketPath); // clear a stale socket file from a crashed prior run + + m_token = GenerateAndPersistToken(); + if (m_token.isEmpty()) + return false; + + if (!m_server.listen(socketPath)) + return false; + + // QLocalServer on Linux creates the socket file with the process umask; tighten + // it explicitly to 0600 rather than relying on the caller's umask being correct. + ::chmod(socketPath.toUtf8().constData(), S_IRUSR | S_IWUSR); + + connect(&m_server, &QLocalServer::newConnection, this, &GatewayServer::OnNewConnection); + return true; + } + + void GatewayServer::Stop() + { + m_server.close(); + QFile::remove(SocketPath()); + QFile::remove(TokenPath()); + } + + void GatewayServer::OnNewConnection() + { + while (QLocalSocket* client = m_server.nextPendingConnection()) + { + // One-shot request/response per connection for now, matching eo-ctl's + // `call` subcommand (connect, send one command, read one response, + // disconnect) — see cdp-gateway-cli-plan.md §7. A persistent-connection + // mode is not needed until a caller actually wants one. + connect(client, &QLocalSocket::readyRead, this, [this, client]() { + if (!client->canReadLine()) + return; + + const QByteArray line = client->readLine().trimmed(); + const QJsonObject request = QJsonDocument::fromJson(line).object(); + + QJsonObject response; + response.insert(QStringLiteral("id"), request.value(QStringLiteral("id"))); + + if (request.value(QStringLiteral("auth")).toString() != m_token) + { + QJsonObject error; + error.insert(QStringLiteral("code"), ErrorCodeToString(ErrorCode::Unauthenticated)); + error.insert(QStringLiteral("message"), QStringLiteral("invalid or missing auth token")); + response.insert(QStringLiteral("ok"), false); + response.insert(QStringLiteral("error"), error); + } + else + { + const QString command = request.value(QStringLiteral("command")).toString(); + + // Meta command, not part of any editor's allowlist table and not + // dispatched through CDP — it just reads AllowlistTable's own + // registry. Kept as a special case here rather than in + // GatewayCommandRunner so Execute() stays "one command == one CDP + // round trip", matching every functional test case's assumption. + if (command == QStringLiteral("gateway.listCommands")) + { + QJsonArray names; + for (const QString& name : AllowlistTable::Instance().ListCommandNames()) + names.append(name); + response.insert(QStringLiteral("ok"), true); + response.insert(QStringLiteral("result"), names); + } + else if (command == QStringLiteral("gateway.connect")) + { + // Meta command, same rationale as gateway.listCommands above: + // a pure resolver (GatewayCommandRunner::ResolveViewIdByPath), + // not a CDP round trip, so it stays a special case here rather + // than going through the allowlist. Deliberately does NOT open + // anything itself -- see that method's header comment. Returns + // targetViewId: -1 (not an error) when the path isn't open yet, + // so a polling client can distinguish "not open yet, keep + // waiting" from a real failure. + const QJsonObject scope = request.value(QStringLiteral("scope")).toObject(); + const QString path = scope.value(QStringLiteral("path")).toString(); + if (path.isEmpty()) + { + QJsonObject error; + error.insert(QStringLiteral("code"), ErrorCodeToString(ErrorCode::SchemaInvalid)); + error.insert(QStringLiteral("message"), QStringLiteral("scope.path must be a non-empty string")); + response.insert(QStringLiteral("ok"), false); + response.insert(QStringLiteral("error"), error); + } + else + { + QJsonObject result; + result.insert(QStringLiteral("targetViewId"), m_runner->ResolveViewIdByPath(path)); + response.insert(QStringLiteral("ok"), true); + response.insert(QStringLiteral("result"), result); + } + } + else + { + const QJsonObject scope = request.value(QStringLiteral("scope")).toObject(); + const int targetViewId = request.value(QStringLiteral("targetViewId")).toInt(-1); + + const Result result = m_runner->Execute(command, scope, targetViewId); + response.insert(QStringLiteral("ok"), result.ok); + if (result.ok) + { + response.insert(QStringLiteral("result"), result.value); + } + else + { + QJsonObject error; + error.insert(QStringLiteral("code"), ErrorCodeToString(result.error.code)); + error.insert(QStringLiteral("message"), result.error.message); + response.insert(QStringLiteral("error"), error); + } + } + } + + client->write(QJsonDocument(response).toJson(QJsonDocument::Compact) + '\n'); + client->flush(); + client->disconnectFromServer(); + }); + + connect(client, &QLocalSocket::disconnected, client, &QLocalSocket::deleteLater); + } + } +} diff --git a/win-linux/src/gateway/gatewayserver.h b/win-linux/src/gateway/gatewayserver.h new file mode 100644 index 000000000..38dd2cfef --- /dev/null +++ b/win-linux/src/gateway/gatewayserver.h @@ -0,0 +1,47 @@ +#ifndef GATEWAY_GATEWAYSERVER_H +#define GATEWAY_GATEWAYSERVER_H + +#include +#include +#include + +class CAscApplicationManager; + +namespace Gateway +{ + class GatewayCommandRunner; + + // Authenticated external listener. Terminates the unix-socket transport (QLocalServer + // on Linux is a real AF_UNIX SOCK_STREAM socket — see cdp-gateway-cli-plan.md §0 for + // why this is NOT built on desktop-apps' existing singleapplication.cpp/CSocket UDP + // mechanism). tcp-loopback transport is a documented future addition, not implemented + // in this pass (--transport flag currently only accepts "unix-socket"). + class GatewayServer : public QObject + { + Q_OBJECT + public: + explicit GatewayServer(CAscApplicationManager* manager, QObject* parent = nullptr); + + // Starts listening on $XDG_RUNTIME_DIR/eo-gateway-.sock (mode 0600) and + // writes a fresh random token to $XDG_RUNTIME_DIR/eo-gateway-.token + // (mode 0600) per plan §0. Returns false if either the socket or the token + // file could not be created (e.g. a stale gateway from a crashed previous + // instance still holds the socket path). + bool Start(); + void Stop(); + + private slots: + void OnNewConnection(); + + private: + static QString SocketPath(); + static QString TokenPath(); + QString GenerateAndPersistToken(); + + QLocalServer m_server; + QString m_token; + GatewayCommandRunner* m_runner; + }; +} + +#endif // GATEWAY_GATEWAYSERVER_H diff --git a/win-linux/src/gateway/gatewaytypes.h b/win-linux/src/gateway/gatewaytypes.h new file mode 100644 index 000000000..4a545a0d5 --- /dev/null +++ b/win-linux/src/gateway/gatewaytypes.h @@ -0,0 +1,55 @@ +#ifndef GATEWAYTYPES_H +#define GATEWAYTYPES_H + +#include +#include + +// Shared result/error shapes for GatewayCommandRunner::Execute(), used identically +// by the CLI (eo-ctl) and the external gateway wire protocol — both are thin shells +// around the same call, see cdp-gateway-cli-plan.md. + +namespace Gateway +{ + enum class ErrorCode + { + NotAllowlisted, + SchemaInvalid, + TargetNotFound, + ScriptException, + Unauthenticated + }; + + inline const char* ErrorCodeToString(ErrorCode code) + { + switch (code) + { + case ErrorCode::NotAllowlisted: return "NOT_ALLOWLISTED"; + case ErrorCode::SchemaInvalid: return "SCHEMA_INVALID"; + case ErrorCode::TargetNotFound: return "TARGET_NOT_FOUND"; + case ErrorCode::ScriptException:return "SCRIPT_EXCEPTION"; + case ErrorCode::Unauthenticated:return "UNAUTHENTICATED"; + } + return "UNKNOWN"; + } + + struct Error + { + ErrorCode code; + QString message; + }; + + struct Result + { + bool ok = false; + QJsonValue value; // present when ok == true + Error error{ErrorCode::ScriptException, QString()}; // present when ok == false + + static Result Success(const QJsonValue& v) { Result r; r.ok = true; r.value = v; return r; } + static Result Failure(ErrorCode code, const QString& message) + { + Result r; r.ok = false; r.error = Error{code, message}; return r; + } + }; +} + +#endif // GATEWAYTYPES_H diff --git a/win-linux/src/main.cpp b/win-linux/src/main.cpp index 271f9e08c..ba1833cdd 100644 --- a/win-linux/src/main.cpp +++ b/win-linux/src/main.cpp @@ -32,6 +32,7 @@ # include #endif #include "cascapplicationmanagerwrapper.h" +#include "gateway/gatewayserver.h" #include "defines.h" #include "clangater.h" #include "clogger.h" @@ -185,6 +186,18 @@ int main( int argc, char *argv[] ) CLangater::init(); AscAppManager::initializeApp(); AscAppManager::startApp(); + + // Authenticated command gateway (see cdp-gateway-cli-plan.md). Started once here, + // not per-window: AscAppManager::getInstance() is a process-wide Meyers singleton + // (cascapplicationmanagerwrapper.cpp) and CAscApplicationManager_Private's view map + // is shared across every editor window/view in this process, so one GatewayServer + // instance already covers all of them. Single-instance-per-user-session is already + // guaranteed upstream by the SingleApplication::isPrimary() check earlier in this + // function -- a second launch forwards its args and exits before reaching here. + static Gateway::GatewayServer gatewayServer(&AscAppManager::getInstance()); + if (!gatewayServer.Start()) + CLogger::log("eo-gateway: failed to start (socket or token file could not be created)"); + AscAppManager::getInstance().StartSpellChecker(); AscAppManager::getInstance().StartKeyboardChecker(); AscAppManager::getInstance().CheckFonts(); diff --git a/win-linux/tests/gateway/CMakeLists.txt b/win-linux/tests/gateway/CMakeLists.txt new file mode 100644 index 000000000..43a72eeae --- /dev/null +++ b/win-linux/tests/gateway/CMakeLists.txt @@ -0,0 +1,972 @@ +# First automated-test target in desktop-apps (none existed before this — see +# cdp-gateway-cli-plan.md investigation notes). Uses CTest, which ships with CMake +# itself, rather than adding gtest/Catch2/QtTest as a new external test-framework +# dependency — YAGNI given the harness needs are currently just "run these functions, +# report pass/fail" (see word_document_properties_test.cpp's own header comment for +# what is and isn't testable yet). + +find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core) + +# Unlike the app's real targets (DesktopEditors, x2t, ...), these test executables +# never go through set_default_options() (core/common.cmake) or the app's install +# step, so they get no RPATH and no copy of Qt's .so files next to them -- ctest runs +# them straight out of the build tree, where they'd otherwise fail with +# "libQt6Core.so.6: cannot open shared object file". +# +# QT_ROOT is set globally by core/common.cmake -- Qt6 here comes from aqtinstall +# (fetched to third_party/install/qt//), NOT from vcpkg, confirmed via +# `find /build-cache-desktop -iname libQt6Core.so*` against an actual build +# (.../qt/6.10.1/gcc_64/lib/libQt6Core.so.6) after two wrong guesses (vcpkg's install +# tree has no Qt in it at all; it only happened to contain other vcpkg-built .so files +# like libvulkan.so, which is what misled the first attempt). +# +# CMAKE_INSTALL_RPATH (not CMAKE_BUILD_RPATH) is required here because +# core/common.cmake sets CMAKE_BUILD_WITH_INSTALL_RPATH TRUE globally, which makes +# build-tree binaries use each target's INSTALL_RPATH property instead of the +# auto-computed build RPATH. +set(CMAKE_INSTALL_RPATH "${QT_ROOT}/lib") + +add_executable(gateway_word_document_properties_test + word_document_properties_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/wordcommands.cpp +) + +set_target_properties(gateway_word_document_properties_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_word_document_properties_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_word_document_properties + COMMAND gateway_word_document_properties_test +) + +add_executable(gateway_word_content_enumeration_test + word_content_enumeration_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/wordcommands.cpp +) + +set_target_properties(gateway_word_content_enumeration_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_word_content_enumeration_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_word_content_enumeration + COMMAND gateway_word_content_enumeration_test +) + +add_executable(gateway_word_insert_edit_text_test + word_insert_edit_text_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/wordcommands.cpp +) + +set_target_properties(gateway_word_insert_edit_text_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_word_insert_edit_text_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_word_insert_edit_text + COMMAND gateway_word_insert_edit_text_test +) + +add_executable(gateway_word_character_formatting_test + word_character_formatting_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/wordcommands.cpp +) + +set_target_properties(gateway_word_character_formatting_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_word_character_formatting_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_word_character_formatting + COMMAND gateway_word_character_formatting_test +) + +add_executable(gateway_word_paragraph_formatting_test + word_paragraph_formatting_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/wordcommands.cpp +) + +set_target_properties(gateway_word_paragraph_formatting_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_word_paragraph_formatting_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_word_paragraph_formatting + COMMAND gateway_word_paragraph_formatting_test +) + +add_executable(gateway_word_search_replace_test + word_search_replace_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/wordcommands.cpp +) + +set_target_properties(gateway_word_search_replace_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_word_search_replace_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_word_search_replace + COMMAND gateway_word_search_replace_test +) + +add_executable(gateway_word_table_test + word_table_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/wordcommands.cpp +) + +set_target_properties(gateway_word_table_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_word_table_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_word_table + COMMAND gateway_word_table_test +) + +add_executable(gateway_word_style_test + word_style_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/wordcommands.cpp +) + +set_target_properties(gateway_word_style_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_word_style_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_word_style + COMMAND gateway_word_style_test +) + +add_executable(gateway_word_image_shape_test + word_image_shape_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/wordcommands.cpp +) + +set_target_properties(gateway_word_image_shape_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_word_image_shape_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_word_image_shape + COMMAND gateway_word_image_shape_test +) + +add_executable(gateway_word_page_setup_test + word_page_setup_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/wordcommands.cpp +) + +set_target_properties(gateway_word_page_setup_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_word_page_setup_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_word_page_setup + COMMAND gateway_word_page_setup_test +) + +add_executable(gateway_word_bookmark_hyperlink_test + word_bookmark_hyperlink_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/wordcommands.cpp +) + +set_target_properties(gateway_word_bookmark_hyperlink_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_word_bookmark_hyperlink_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_word_bookmark_hyperlink + COMMAND gateway_word_bookmark_hyperlink_test +) + +add_executable(gateway_word_form_field_test + word_form_field_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/wordcommands.cpp +) + +set_target_properties(gateway_word_form_field_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_word_form_field_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_word_form_field + COMMAND gateway_word_form_field_test +) + +add_executable(gateway_word_comment_revision_test + word_comment_revision_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/wordcommands.cpp +) + +set_target_properties(gateway_word_comment_revision_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_word_comment_revision_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_word_comment_revision + COMMAND gateway_word_comment_revision_test +) + +add_executable(gateway_cell_sheet_management_test + cell_sheet_management_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/cellcommands.cpp +) + +set_target_properties(gateway_cell_sheet_management_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_cell_sheet_management_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_cell_sheet_management + COMMAND gateway_cell_sheet_management_test +) + +add_executable(gateway_cell_range_read_write_test + cell_range_read_write_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/cellcommands.cpp +) + +set_target_properties(gateway_cell_range_read_write_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_cell_range_read_write_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_cell_range_read_write + COMMAND gateway_cell_range_read_write_test +) + +add_executable(gateway_cell_number_format_merge_clear_test + cell_number_format_merge_clear_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/cellcommands.cpp +) + +set_target_properties(gateway_cell_number_format_merge_clear_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_cell_number_format_merge_clear_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_cell_number_format_merge_clear + COMMAND gateway_cell_number_format_merge_clear_test +) + +add_executable(gateway_cell_copy_find_replace_test + cell_copy_find_replace_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/cellcommands.cpp +) + +set_target_properties(gateway_cell_copy_find_replace_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_cell_copy_find_replace_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_cell_copy_find_replace + COMMAND gateway_cell_copy_find_replace_test +) + +add_executable(gateway_cell_formatting_test + cell_formatting_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/cellcommands.cpp +) + +set_target_properties(gateway_cell_formatting_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_cell_formatting_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_cell_formatting + COMMAND gateway_cell_formatting_test +) + +add_executable(gateway_cell_conditional_formatting_test + cell_conditional_formatting_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/cellcommands.cpp +) + +set_target_properties(gateway_cell_conditional_formatting_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_cell_conditional_formatting_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_cell_conditional_formatting + COMMAND gateway_cell_conditional_formatting_test +) + +add_executable(gateway_cell_validation_defname_test + cell_validation_defname_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/cellcommands.cpp +) + +set_target_properties(gateway_cell_validation_defname_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_cell_validation_defname_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_cell_validation_defname + COMMAND gateway_cell_validation_defname_test +) + +add_executable(gateway_cell_autofilter_test + cell_autofilter_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/cellcommands.cpp +) + +set_target_properties(gateway_cell_autofilter_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_cell_autofilter_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_cell_autofilter + COMMAND gateway_cell_autofilter_test +) + +add_executable(gateway_cell_pivot_table_test + cell_pivot_table_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/cellcommands.cpp +) + +set_target_properties(gateway_cell_pivot_table_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_cell_pivot_table_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_cell_pivot_table + COMMAND gateway_cell_pivot_table_test +) + +add_executable(gateway_cell_freeze_panes_test + cell_freeze_panes_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/cellcommands.cpp +) + +set_target_properties(gateway_cell_freeze_panes_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_cell_freeze_panes_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_cell_freeze_panes + COMMAND gateway_cell_freeze_panes_test +) + +add_executable(gateway_cell_image_ole_test + cell_image_ole_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/cellcommands.cpp +) + +set_target_properties(gateway_cell_image_ole_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_cell_image_ole_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_cell_image_ole + COMMAND gateway_cell_image_ole_test +) + +add_executable(gateway_cell_comment_reply_test + cell_comment_reply_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/cellcommands.cpp +) + +set_target_properties(gateway_cell_comment_reply_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_cell_comment_reply_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_cell_comment_reply + COMMAND gateway_cell_comment_reply_test +) + +add_executable(gateway_cell_insert_delete_rowcol_test + cell_insert_delete_rowcol_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/cellcommands.cpp +) + +set_target_properties(gateway_cell_insert_delete_rowcol_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_cell_insert_delete_rowcol_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_cell_insert_delete_rowcol + COMMAND gateway_cell_insert_delete_rowcol_test +) + +add_executable(gateway_cell_recalculate_test + cell_recalculate_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/cellcommands.cpp +) + +set_target_properties(gateway_cell_recalculate_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_cell_recalculate_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_cell_recalculate + COMMAND gateway_cell_recalculate_test +) + +add_executable(gateway_cell_chart_test + cell_chart_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/cellcommands.cpp +) + +set_target_properties(gateway_cell_chart_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_cell_chart_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_cell_chart + COMMAND gateway_cell_chart_test +) + +add_executable(gateway_cell_smartart_test + cell_smartart_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/cellcommands.cpp +) + +set_target_properties(gateway_cell_smartart_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_cell_smartart_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_cell_smartart + COMMAND gateway_cell_smartart_test +) + +add_executable(gateway_slide_management_test + slide_management_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/slidecommands.cpp +) + +set_target_properties(gateway_slide_management_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_slide_management_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_slide_management + COMMAND gateway_slide_management_test +) + +add_executable(gateway_slide_content_enumeration_test + slide_content_enumeration_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/slidecommands.cpp +) + +set_target_properties(gateway_slide_content_enumeration_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_slide_content_enumeration_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_slide_content_enumeration + COMMAND gateway_slide_content_enumeration_test +) + +add_executable(gateway_slide_layout_master_test + slide_layout_master_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/slidecommands.cpp +) + +set_target_properties(gateway_slide_layout_master_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_slide_layout_master_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_slide_layout_master + COMMAND gateway_slide_layout_master_test +) + +add_executable(gateway_slide_transition_test + slide_transition_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/slidecommands.cpp +) + +set_target_properties(gateway_slide_transition_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_slide_transition_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_slide_transition + COMMAND gateway_slide_transition_test +) + +add_executable(gateway_slide_shape_test + slide_shape_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/slidecommands.cpp +) + +set_target_properties(gateway_slide_shape_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_slide_shape_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_slide_shape + COMMAND gateway_slide_shape_test +) + +add_executable(gateway_slide_text_formatting_test + slide_text_formatting_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/slidecommands.cpp +) + +set_target_properties(gateway_slide_text_formatting_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_slide_text_formatting_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_slide_text_formatting + COMMAND gateway_slide_text_formatting_test +) + +add_executable(gateway_slide_image_test + slide_image_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/slidecommands.cpp +) + +set_target_properties(gateway_slide_image_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_slide_image_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_slide_image + COMMAND gateway_slide_image_test +) + +add_executable(gateway_slide_table_test + slide_table_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/slidecommands.cpp +) + +set_target_properties(gateway_slide_table_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_slide_table_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_slide_table + COMMAND gateway_slide_table_test +) + +add_executable(gateway_slide_notes_test + slide_notes_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/slidecommands.cpp +) + +set_target_properties(gateway_slide_notes_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_slide_notes_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_slide_notes + COMMAND gateway_slide_notes_test +) + +add_executable(gateway_slide_comment_test + slide_comment_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/slidecommands.cpp +) + +set_target_properties(gateway_slide_comment_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_slide_comment_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_slide_comment + COMMAND gateway_slide_comment_test +) + +add_executable(gateway_slide_document_properties_test + slide_document_properties_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/slidecommands.cpp +) + +set_target_properties(gateway_slide_document_properties_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_slide_document_properties_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_slide_document_properties + COMMAND gateway_slide_document_properties_test +) + +add_executable(gateway_pdf_form_field_test + pdf_form_field_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/pdfcommands.cpp +) + +set_target_properties(gateway_pdf_form_field_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_pdf_form_field_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_pdf_form_field + COMMAND gateway_pdf_form_field_test +) + +add_executable(gateway_pdf_annotation_test + pdf_annotation_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/pdfcommands.cpp +) + +set_target_properties(gateway_pdf_annotation_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_pdf_annotation_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_pdf_annotation + COMMAND gateway_pdf_annotation_test +) + +add_executable(gateway_pdf_text_search_test + pdf_text_search_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/pdfcommands.cpp +) + +set_target_properties(gateway_pdf_text_search_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_pdf_text_search_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_pdf_text_search + COMMAND gateway_pdf_text_search_test +) + +add_executable(gateway_pdf_redaction_test + pdf_redaction_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/pdfcommands.cpp +) + +set_target_properties(gateway_pdf_redaction_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_pdf_redaction_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_pdf_redaction + COMMAND gateway_pdf_redaction_test +) + +add_executable(gateway_pdf_page_operations_test + pdf_page_operations_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/allowlist.cpp + ${CMAKE_CURRENT_LIST_DIR}/../../src/gateway/commands/pdfcommands.cpp +) + +set_target_properties(gateway_pdf_page_operations_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(gateway_pdf_page_operations_test PRIVATE + Qt${QT_VERSION_MAJOR}::Core +) + +add_test( + NAME gateway_pdf_page_operations + COMMAND gateway_pdf_page_operations_test +) diff --git a/win-linux/tests/gateway/cell_autofilter_test.cpp b/win-linux/tests/gateway/cell_autofilter_test.cpp new file mode 100644 index 000000000..a9c38179d --- /dev/null +++ b/win-linux/tests/gateway/cell_autofilter_test.cpp @@ -0,0 +1,76 @@ +// Test cases from gateway-test-case-designs.md §C8 (Cell AutoFilter). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/cellcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_ApplyFilter_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.applyFilter")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1:C10")); + return spec->validate(scope).isEmpty(); + } + + bool Test_ApplyFilter_MissingRange_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.applyFilter")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + return !spec->validate(scope).isEmpty(); + } + + bool Test_GetFilters_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.getFilters")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + return spec->validate(scope).isEmpty(); + } + + bool Test_GetFilters_MissingSheet_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.getFilters")); + if (!spec) return false; + return !spec->validate(QJsonObject{}).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterCellCommands(); + + const std::vector>> tests = { + {"ApplyFilter_ValidScope_PassesValidation", Test_ApplyFilter_ValidScope_PassesValidation}, + {"ApplyFilter_MissingRange_SchemaInvalid", Test_ApplyFilter_MissingRange_SchemaInvalid}, + {"GetFilters_ValidScope_PassesValidation", Test_GetFilters_ValidScope_PassesValidation}, + {"GetFilters_MissingSheet_SchemaInvalid", Test_GetFilters_MissingSheet_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/cell_chart_test.cpp b/win-linux/tests/gateway/cell_chart_test.cpp new file mode 100644 index 000000000..a552b5cef --- /dev/null +++ b/win-linux/tests/gateway/cell_chart_test.cpp @@ -0,0 +1,85 @@ +// Test cases from gateway-test-case-designs.md §C15 (Cell create charts and edit data +// series). See word_document_properties_test.cpp's header comment for scope/limits +// shared by every file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/cellcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_AddSeria_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addSeria")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("chartIndex"), 0); + scope.insert(QStringLiteral("valuesRange"), QStringLiteral("Sheet1!B1:B10")); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddSeria_MissingValuesRange_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addSeria")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("chartIndex"), 0); + return !spec->validate(scope).isEmpty(); + } + + bool Test_SetSeriaName_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setSeriaName")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("chartIndex"), 0); + scope.insert(QStringLiteral("seriaIndex"), 0); + scope.insert(QStringLiteral("name"), QStringLiteral("Revenue")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetSeriaName_MissingSeriaIndex_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setSeriaName")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("chartIndex"), 0); + scope.insert(QStringLiteral("name"), QStringLiteral("Revenue")); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterCellCommands(); + + const std::vector>> tests = { + {"AddSeria_ValidScope_PassesValidation", Test_AddSeria_ValidScope_PassesValidation}, + {"AddSeria_MissingValuesRange_SchemaInvalid", Test_AddSeria_MissingValuesRange_SchemaInvalid}, + {"SetSeriaName_ValidScope_PassesValidation", Test_SetSeriaName_ValidScope_PassesValidation}, + {"SetSeriaName_MissingSeriaIndex_SchemaInvalid", Test_SetSeriaName_MissingSeriaIndex_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/cell_comment_reply_test.cpp b/win-linux/tests/gateway/cell_comment_reply_test.cpp new file mode 100644 index 000000000..670623ca5 --- /dev/null +++ b/win-linux/tests/gateway/cell_comment_reply_test.cpp @@ -0,0 +1,109 @@ +// Test cases from gateway-test-case-designs.md §C12 (Cell comments with replies). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/cellcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_AddComment_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addComment")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1")); + scope.insert(QStringLiteral("text"), QStringLiteral("check this")); + scope.insert(QStringLiteral("author"), QStringLiteral("peter")); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddComment_EmptyText_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addComment")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1")); + scope.insert(QStringLiteral("text"), QString()); + return !spec->validate(scope).isEmpty(); + } + + bool Test_AddReply_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addReply")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("commentId"), QStringLiteral("comment-1")); + scope.insert(QStringLiteral("text"), QStringLiteral("done")); + scope.insert(QStringLiteral("author"), QStringLiteral("jane")); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddReply_MissingCommentId_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addReply")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("text"), QStringLiteral("done")); + return !spec->validate(scope).isEmpty(); + } + + bool Test_SetSolved_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setSolved")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("commentId"), QStringLiteral("comment-1")); + scope.insert(QStringLiteral("solved"), true); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetSolved_MissingSolved_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setSolved")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("commentId"), QStringLiteral("comment-1")); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterCellCommands(); + + const std::vector>> tests = { + {"AddComment_ValidScope_PassesValidation", Test_AddComment_ValidScope_PassesValidation}, + {"AddComment_EmptyText_SchemaInvalid", Test_AddComment_EmptyText_SchemaInvalid}, + {"AddReply_ValidScope_PassesValidation", Test_AddReply_ValidScope_PassesValidation}, + {"AddReply_MissingCommentId_SchemaInvalid", Test_AddReply_MissingCommentId_SchemaInvalid}, + {"SetSolved_ValidScope_PassesValidation", Test_SetSolved_ValidScope_PassesValidation}, + {"SetSolved_MissingSolved_SchemaInvalid", Test_SetSolved_MissingSolved_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/cell_conditional_formatting_test.cpp b/win-linux/tests/gateway/cell_conditional_formatting_test.cpp new file mode 100644 index 000000000..07ac3321b --- /dev/null +++ b/win-linux/tests/gateway/cell_conditional_formatting_test.cpp @@ -0,0 +1,83 @@ +// Test cases from gateway-test-case-designs.md §C6 (Cell conditional formatting). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/cellcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_AddColorScale_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addColorScale")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1:A10")); + scope.insert(QStringLiteral("scaleType"), 3); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddColorScale_ScaleTypeTooLow_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addColorScale")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1:A10")); + scope.insert(QStringLiteral("scaleType"), 1); + return !spec->validate(scope).isEmpty(); + } + + bool Test_AddDatabar_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addDatabar")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1:A10")); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddIconSetCondition_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addIconSetCondition")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1:A10")); + return spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterCellCommands(); + + const std::vector>> tests = { + {"AddColorScale_ValidScope_PassesValidation", Test_AddColorScale_ValidScope_PassesValidation}, + {"AddColorScale_ScaleTypeTooLow_SchemaInvalid", Test_AddColorScale_ScaleTypeTooLow_SchemaInvalid}, + {"AddDatabar_ValidScope_PassesValidation", Test_AddDatabar_ValidScope_PassesValidation}, + {"AddIconSetCondition_ValidScope_PassesValidation", Test_AddIconSetCondition_ValidScope_PassesValidation}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/cell_copy_find_replace_test.cpp b/win-linux/tests/gateway/cell_copy_find_replace_test.cpp new file mode 100644 index 000000000..ceb52b5bf --- /dev/null +++ b/win-linux/tests/gateway/cell_copy_find_replace_test.cpp @@ -0,0 +1,94 @@ +// Test cases from gateway-test-case-designs.md §C4 (Cell copy/paste, find/replace). +// See word_document_properties_test.cpp's header comment for scope/limits shared by +// every file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/cellcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_Copy_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.copy")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("from"), QStringLiteral("A1")); + scope.insert(QStringLiteral("to"), QStringLiteral("B1")); + return spec->validate(scope).isEmpty(); + } + + bool Test_Copy_MissingTo_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.copy")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("from"), QStringLiteral("A1")); + return !spec->validate(scope).isEmpty(); + } + + bool Test_Find_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.find")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("text"), QStringLiteral("foo")); + return spec->validate(scope).isEmpty(); + } + + bool Test_Replace_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.replace")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("find"), QStringLiteral("foo")); + scope.insert(QStringLiteral("replace"), QStringLiteral("baz")); + return spec->validate(scope).isEmpty(); + } + + bool Test_Replace_MissingFind_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.replace")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("replace"), QStringLiteral("baz")); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterCellCommands(); + + const std::vector>> tests = { + {"Copy_ValidScope_PassesValidation", Test_Copy_ValidScope_PassesValidation}, + {"Copy_MissingTo_SchemaInvalid", Test_Copy_MissingTo_SchemaInvalid}, + {"Find_ValidScope_PassesValidation", Test_Find_ValidScope_PassesValidation}, + {"Replace_ValidScope_PassesValidation", Test_Replace_ValidScope_PassesValidation}, + {"Replace_MissingFind_SchemaInvalid", Test_Replace_MissingFind_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/cell_formatting_test.cpp b/win-linux/tests/gateway/cell_formatting_test.cpp new file mode 100644 index 000000000..13699e1ac --- /dev/null +++ b/win-linux/tests/gateway/cell_formatting_test.cpp @@ -0,0 +1,125 @@ +// Test cases from gateway-test-case-designs.md §C5 (Cell font/fill/border/alignment +// formatting). See word_document_properties_test.cpp's header comment for +// scope/limits shared by every file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/cellcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_SetFontName_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setFontName")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1")); + scope.insert(QStringLiteral("font"), QStringLiteral("Calibri")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetFillColor_ValidHex_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setFillColor")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1")); + scope.insert(QStringLiteral("color"), QStringLiteral("#FFFF00")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetFillColor_InvalidColor_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setFillColor")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1")); + scope.insert(QStringLiteral("color"), QStringLiteral("yellow")); + return !spec->validate(scope).isEmpty(); + } + + bool Test_SetBorders_AllEdges_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setBorders")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1")); + scope.insert(QStringLiteral("edge"), QStringLiteral("all")); + scope.insert(QStringLiteral("style"), QStringLiteral("Thin")); + scope.insert(QStringLiteral("color"), QStringLiteral("#000000")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetBorders_UnknownEdge_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setBorders")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1")); + scope.insert(QStringLiteral("edge"), QStringLiteral("Sideways")); + scope.insert(QStringLiteral("style"), QStringLiteral("Thin")); + scope.insert(QStringLiteral("color"), QStringLiteral("#000000")); + return !spec->validate(scope).isEmpty(); + } + + bool Test_SetAlignHorizontal_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setAlignHorizontal")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1")); + scope.insert(QStringLiteral("align"), QStringLiteral("center")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetAlignHorizontal_UnknownAlign_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setAlignHorizontal")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1")); + scope.insert(QStringLiteral("align"), QStringLiteral("diagonal")); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterCellCommands(); + + const std::vector>> tests = { + {"SetFontName_ValidScope_PassesValidation", Test_SetFontName_ValidScope_PassesValidation}, + {"SetFillColor_ValidHex_PassesValidation", Test_SetFillColor_ValidHex_PassesValidation}, + {"SetFillColor_InvalidColor_SchemaInvalid", Test_SetFillColor_InvalidColor_SchemaInvalid}, + {"SetBorders_AllEdges_PassesValidation", Test_SetBorders_AllEdges_PassesValidation}, + {"SetBorders_UnknownEdge_SchemaInvalid", Test_SetBorders_UnknownEdge_SchemaInvalid}, + {"SetAlignHorizontal_ValidScope_PassesValidation", Test_SetAlignHorizontal_ValidScope_PassesValidation}, + {"SetAlignHorizontal_UnknownAlign_SchemaInvalid", Test_SetAlignHorizontal_UnknownAlign_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/cell_freeze_panes_test.cpp b/win-linux/tests/gateway/cell_freeze_panes_test.cpp new file mode 100644 index 000000000..50c6c19c0 --- /dev/null +++ b/win-linux/tests/gateway/cell_freeze_panes_test.cpp @@ -0,0 +1,58 @@ +// Test cases from gateway-test-case-designs.md §C10 (Cell freeze panes). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/cellcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_FreezeAt_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.freezeAt")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("B2")); + return spec->validate(scope).isEmpty(); + } + + bool Test_FreezeAt_MissingRange_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.freezeAt")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterCellCommands(); + + const std::vector>> tests = { + {"FreezeAt_ValidScope_PassesValidation", Test_FreezeAt_ValidScope_PassesValidation}, + {"FreezeAt_MissingRange_SchemaInvalid", Test_FreezeAt_MissingRange_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/cell_image_ole_test.cpp b/win-linux/tests/gateway/cell_image_ole_test.cpp new file mode 100644 index 000000000..d07c4ed91 --- /dev/null +++ b/win-linux/tests/gateway/cell_image_ole_test.cpp @@ -0,0 +1,110 @@ +// Test cases from gateway-test-case-designs.md §C11 (Cell insert images/OLE objects; +// shapes deferred, see that section's header note). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/cellcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + QJsonObject PlacementFields() + { + QJsonObject scope; + scope.insert(QStringLiteral("fromCol"), 0); + scope.insert(QStringLiteral("colOffset"), 0); + scope.insert(QStringLiteral("fromRow"), 0); + scope.insert(QStringLiteral("rowOffset"), 0); + return scope; + } + + bool Test_AddImage_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addImage")); + if (!spec) return false; + QJsonObject scope = PlacementFields(); + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("imageSrc"), QStringLiteral("data:image/png;base64,AAAA")); + scope.insert(QStringLiteral("width"), 914400); + scope.insert(QStringLiteral("height"), 914400); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddImage_MissingPlacementField_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addImage")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("imageSrc"), QStringLiteral("data:image/png;base64,AAAA")); + scope.insert(QStringLiteral("width"), 914400); + scope.insert(QStringLiteral("height"), 914400); + return !spec->validate(scope).isEmpty(); + } + + bool Test_AddOleObject_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addOleObject")); + if (!spec) return false; + QJsonObject scope = PlacementFields(); + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("imageSrc"), QStringLiteral("data:image/png;base64,AAAA")); + scope.insert(QStringLiteral("width"), 914400); + scope.insert(QStringLiteral("height"), 914400); + scope.insert(QStringLiteral("data"), QStringLiteral("payload")); + scope.insert(QStringLiteral("appId"), QStringLiteral("x-office/binary")); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddOleObject_MissingAppId_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addOleObject")); + if (!spec) return false; + QJsonObject scope = PlacementFields(); + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("imageSrc"), QStringLiteral("data:image/png;base64,AAAA")); + scope.insert(QStringLiteral("width"), 914400); + scope.insert(QStringLiteral("height"), 914400); + scope.insert(QStringLiteral("data"), QStringLiteral("payload")); + return !spec->validate(scope).isEmpty(); + } + + // §C11: cell.addShape is deliberately not registered. + bool Test_AddShape_NotImplemented_NotAllowlisted() + { + return Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addShape")) == nullptr; + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterCellCommands(); + + const std::vector>> tests = { + {"AddImage_ValidScope_PassesValidation", Test_AddImage_ValidScope_PassesValidation}, + {"AddImage_MissingPlacementField_SchemaInvalid", Test_AddImage_MissingPlacementField_SchemaInvalid}, + {"AddOleObject_ValidScope_PassesValidation", Test_AddOleObject_ValidScope_PassesValidation}, + {"AddOleObject_MissingAppId_SchemaInvalid", Test_AddOleObject_MissingAppId_SchemaInvalid}, + {"AddShape_NotImplemented_NotAllowlisted", Test_AddShape_NotImplemented_NotAllowlisted}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/cell_insert_delete_rowcol_test.cpp b/win-linux/tests/gateway/cell_insert_delete_rowcol_test.cpp new file mode 100644 index 000000000..59cd4faea --- /dev/null +++ b/win-linux/tests/gateway/cell_insert_delete_rowcol_test.cpp @@ -0,0 +1,81 @@ +// Test cases from gateway-test-case-designs.md §C13 (Cell insert/delete rows and +// columns). See word_document_properties_test.cpp's header comment for scope/limits +// shared by every file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/cellcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_InsertEntireRow_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.insertEntireRow")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("rowIndex"), 1); + return spec->validate(scope).isEmpty(); + } + + // C13.3 + bool Test_InsertEntireRow_NegativeIndex_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.insertEntireRow")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("rowIndex"), -1); + return !spec->validate(scope).isEmpty(); + } + + bool Test_DeleteEntireColumn_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.deleteEntireColumn")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("colIndex"), 0); + return spec->validate(scope).isEmpty(); + } + + bool Test_DeleteEntireColumn_MissingColIndex_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.deleteEntireColumn")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterCellCommands(); + + const std::vector>> tests = { + {"InsertEntireRow_ValidScope_PassesValidation", Test_InsertEntireRow_ValidScope_PassesValidation}, + {"InsertEntireRow_NegativeIndex_SchemaInvalid", Test_InsertEntireRow_NegativeIndex_SchemaInvalid}, + {"DeleteEntireColumn_ValidScope_PassesValidation", Test_DeleteEntireColumn_ValidScope_PassesValidation}, + {"DeleteEntireColumn_MissingColIndex_SchemaInvalid", Test_DeleteEntireColumn_MissingColIndex_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/cell_number_format_merge_clear_test.cpp b/win-linux/tests/gateway/cell_number_format_merge_clear_test.cpp new file mode 100644 index 000000000..063837d71 --- /dev/null +++ b/win-linux/tests/gateway/cell_number_format_merge_clear_test.cpp @@ -0,0 +1,95 @@ +// Test cases from gateway-test-case-designs.md §C3 (Cell number formats, merge, +// clear). See word_document_properties_test.cpp's header comment for scope/limits +// shared by every file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/cellcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_SetNumberFormat_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setNumberFormat")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1")); + scope.insert(QStringLiteral("format"), QStringLiteral("0.00")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetNumberFormat_EmptyFormat_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setNumberFormat")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1")); + scope.insert(QStringLiteral("format"), QString()); + return !spec->validate(scope).isEmpty(); + } + + bool Test_Merge_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.merge")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1:B2")); + scope.insert(QStringLiteral("across"), false); + return spec->validate(scope).isEmpty(); + } + + bool Test_Merge_MissingAcross_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.merge")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1:B2")); + return !spec->validate(scope).isEmpty(); + } + + bool Test_ClearContents_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.clearContents")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1")); + return spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterCellCommands(); + + const std::vector>> tests = { + {"SetNumberFormat_ValidScope_PassesValidation", Test_SetNumberFormat_ValidScope_PassesValidation}, + {"SetNumberFormat_EmptyFormat_SchemaInvalid", Test_SetNumberFormat_EmptyFormat_SchemaInvalid}, + {"Merge_ValidScope_PassesValidation", Test_Merge_ValidScope_PassesValidation}, + {"Merge_MissingAcross_SchemaInvalid", Test_Merge_MissingAcross_SchemaInvalid}, + {"ClearContents_ValidScope_PassesValidation", Test_ClearContents_ValidScope_PassesValidation}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/cell_pivot_table_test.cpp b/win-linux/tests/gateway/cell_pivot_table_test.cpp new file mode 100644 index 000000000..f0a890144 --- /dev/null +++ b/win-linux/tests/gateway/cell_pivot_table_test.cpp @@ -0,0 +1,102 @@ +// Test cases from gateway-test-case-designs.md §C9 (Cell PivotTable). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/cellcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_AddPivotTable_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addPivotTable")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sourceSheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("sourceRange"), QStringLiteral("A1:B10")); + scope.insert(QStringLiteral("pivotSheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("pivotRange"), QStringLiteral("D1")); + scope.insert(QStringLiteral("name"), QStringLiteral("MyPivot")); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddPivotTable_MissingName_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addPivotTable")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sourceSheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("sourceRange"), QStringLiteral("A1:B10")); + scope.insert(QStringLiteral("pivotSheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("pivotRange"), QStringLiteral("D1")); + return !spec->validate(scope).isEmpty(); + } + + bool Test_AddPivotDataField_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addPivotDataField")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("pivotName"), QStringLiteral("MyPivot")); + scope.insert(QStringLiteral("field"), QStringLiteral("Sales")); + scope.insert(QStringLiteral("func"), QStringLiteral("Sum")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetPivotFieldFunction_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setPivotFieldFunction")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("pivotName"), QStringLiteral("MyPivot")); + scope.insert(QStringLiteral("field"), QStringLiteral("Sales")); + scope.insert(QStringLiteral("func"), QStringLiteral("Average")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetPivotFieldFunction_MissingFunc_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setPivotFieldFunction")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("pivotName"), QStringLiteral("MyPivot")); + scope.insert(QStringLiteral("field"), QStringLiteral("Sales")); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterCellCommands(); + + const std::vector>> tests = { + {"AddPivotTable_ValidScope_PassesValidation", Test_AddPivotTable_ValidScope_PassesValidation}, + {"AddPivotTable_MissingName_SchemaInvalid", Test_AddPivotTable_MissingName_SchemaInvalid}, + {"AddPivotDataField_ValidScope_PassesValidation", Test_AddPivotDataField_ValidScope_PassesValidation}, + {"SetPivotFieldFunction_ValidScope_PassesValidation", Test_SetPivotFieldFunction_ValidScope_PassesValidation}, + {"SetPivotFieldFunction_MissingFunc_SchemaInvalid", Test_SetPivotFieldFunction_MissingFunc_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/cell_range_read_write_test.cpp b/win-linux/tests/gateway/cell_range_read_write_test.cpp new file mode 100644 index 000000000..2f37c3b7a --- /dev/null +++ b/win-linux/tests/gateway/cell_range_read_write_test.cpp @@ -0,0 +1,105 @@ +// Test cases from gateway-test-case-designs.md §C2 (Cell/range read & write). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/cellcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_SetValue_NumericValue_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setValue")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1")); + scope.insert(QStringLiteral("value"), 42); + return spec->validate(scope).isEmpty(); + } + + // C2.2: a formula is just a string value starting with "=" -- no separate field. + bool Test_SetValue_FormulaString_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setValue")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1")); + scope.insert(QStringLiteral("value"), QStringLiteral("=1+1")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetValue_MissingValue_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setValue")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1")); + return !spec->validate(scope).isEmpty(); + } + + bool Test_GetValue_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.getValue")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1")); + return spec->validate(scope).isEmpty(); + } + + bool Test_GetFormula_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.getFormula")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1")); + return spec->validate(scope).isEmpty(); + } + + bool Test_GetFormula_MissingRange_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.getFormula")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterCellCommands(); + + const std::vector>> tests = { + {"SetValue_NumericValue_PassesValidation", Test_SetValue_NumericValue_PassesValidation}, + {"SetValue_FormulaString_PassesValidation", Test_SetValue_FormulaString_PassesValidation}, + {"SetValue_MissingValue_SchemaInvalid", Test_SetValue_MissingValue_SchemaInvalid}, + {"GetValue_ValidScope_PassesValidation", Test_GetValue_ValidScope_PassesValidation}, + {"GetFormula_ValidScope_PassesValidation", Test_GetFormula_ValidScope_PassesValidation}, + {"GetFormula_MissingRange_SchemaInvalid", Test_GetFormula_MissingRange_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/cell_recalculate_test.cpp b/win-linux/tests/gateway/cell_recalculate_test.cpp new file mode 100644 index 000000000..6ebfbe00f --- /dev/null +++ b/win-linux/tests/gateway/cell_recalculate_test.cpp @@ -0,0 +1,44 @@ +// Test cases from gateway-test-case-designs.md §C14 (Cell recalculate formulas). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/cellcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_RecalculateAllFormulas_Registered_NoScopeRequired() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.recalculateAllFormulas")); + return spec && spec->validate(QJsonObject{}).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterCellCommands(); + + const std::vector>> tests = { + {"RecalculateAllFormulas_Registered_NoScopeRequired", Test_RecalculateAllFormulas_Registered_NoScopeRequired}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/cell_sheet_management_test.cpp b/win-linux/tests/gateway/cell_sheet_management_test.cpp new file mode 100644 index 000000000..e2d732747 --- /dev/null +++ b/win-linux/tests/gateway/cell_sheet_management_test.cpp @@ -0,0 +1,116 @@ +// Test cases from gateway-test-case-designs.md §C1 (Cell sheet management). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory: schema validation only, round trips deferred to §6. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/cellcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_AddSheet_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addSheet")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("name"), QStringLiteral("Data")); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddSheet_EmptyName_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addSheet")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("name"), QString()); + return !spec->validate(scope).isEmpty(); + } + + bool Test_GetSheets_Registered_NoScopeRequired() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.getSheets")); + return spec && spec->validate(QJsonObject{}).isEmpty(); + } + + bool Test_SetActiveSheet_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setActiveSheet")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("name"), QStringLiteral("Data")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetVisible_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setVisible")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("name"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("visible"), false); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetVisible_MissingVisible_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setVisible")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("name"), QStringLiteral("Sheet1")); + return !spec->validate(scope).isEmpty(); + } + + bool Test_SetName_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setName")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("oldName"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("newName"), QStringLiteral("Renamed")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetName_MissingNewName_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.setName")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("oldName"), QStringLiteral("Sheet1")); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterCellCommands(); + + const std::vector>> tests = { + {"AddSheet_ValidScope_PassesValidation", Test_AddSheet_ValidScope_PassesValidation}, + {"AddSheet_EmptyName_SchemaInvalid", Test_AddSheet_EmptyName_SchemaInvalid}, + {"GetSheets_Registered_NoScopeRequired", Test_GetSheets_Registered_NoScopeRequired}, + {"SetActiveSheet_ValidScope_PassesValidation", Test_SetActiveSheet_ValidScope_PassesValidation}, + {"SetVisible_ValidScope_PassesValidation", Test_SetVisible_ValidScope_PassesValidation}, + {"SetVisible_MissingVisible_SchemaInvalid", Test_SetVisible_MissingVisible_SchemaInvalid}, + {"SetName_ValidScope_PassesValidation", Test_SetName_ValidScope_PassesValidation}, + {"SetName_MissingNewName_SchemaInvalid", Test_SetName_MissingNewName_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/cell_smartart_test.cpp b/win-linux/tests/gateway/cell_smartart_test.cpp new file mode 100644 index 000000000..3e460794f --- /dev/null +++ b/win-linux/tests/gateway/cell_smartart_test.cpp @@ -0,0 +1,59 @@ +// Test cases from gateway-test-case-designs.md §C16 (Cell read SmartArt object type). +// See word_document_properties_test.cpp's header comment for scope/limits shared by +// every file in this directory. This is the last of the 16 Cell command families per +// cdp-gateway-cli-plan.md §4. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/cellcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_GetSmartArtClassType_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.getSmartArtClassType")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("index"), 0); + return spec->validate(scope).isEmpty(); + } + + bool Test_GetSmartArtClassType_MissingIndex_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.getSmartArtClassType")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterCellCommands(); + + const std::vector>> tests = { + {"GetSmartArtClassType_ValidScope_PassesValidation", Test_GetSmartArtClassType_ValidScope_PassesValidation}, + {"GetSmartArtClassType_MissingIndex_SchemaInvalid", Test_GetSmartArtClassType_MissingIndex_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/cell_validation_defname_test.cpp b/win-linux/tests/gateway/cell_validation_defname_test.cpp new file mode 100644 index 000000000..98cd70005 --- /dev/null +++ b/win-linux/tests/gateway/cell_validation_defname_test.cpp @@ -0,0 +1,86 @@ +// Test cases from gateway-test-case-designs.md §C7 (Cell data validation and named +// ranges). See word_document_properties_test.cpp's header comment for scope/limits +// shared by every file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/cellcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_AddValidation_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addValidation")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1")); + scope.insert(QStringLiteral("type"), QStringLiteral("xlValidateWholeNumber")); + scope.insert(QStringLiteral("operator"), QStringLiteral("xlBetween")); + scope.insert(QStringLiteral("formula1"), QStringLiteral("1")); + scope.insert(QStringLiteral("formula2"), QStringLiteral("10")); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddValidation_MissingFormula1_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addValidation")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("sheet"), QStringLiteral("Sheet1")); + scope.insert(QStringLiteral("range"), QStringLiteral("A1")); + scope.insert(QStringLiteral("type"), QStringLiteral("xlValidateWholeNumber")); + scope.insert(QStringLiteral("operator"), QStringLiteral("xlBetween")); + return !spec->validate(scope).isEmpty(); + } + + bool Test_AddDefName_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addDefName")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("name"), QStringLiteral("MyRange")); + scope.insert(QStringLiteral("refersTo"), QStringLiteral("Sheet1!$A$1:$A$5")); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddDefName_MissingRefersTo_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("cell.addDefName")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("name"), QStringLiteral("MyRange")); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterCellCommands(); + + const std::vector>> tests = { + {"AddValidation_ValidScope_PassesValidation", Test_AddValidation_ValidScope_PassesValidation}, + {"AddValidation_MissingFormula1_SchemaInvalid", Test_AddValidation_MissingFormula1_SchemaInvalid}, + {"AddDefName_ValidScope_PassesValidation", Test_AddDefName_ValidScope_PassesValidation}, + {"AddDefName_MissingRefersTo_SchemaInvalid", Test_AddDefName_MissingRefersTo_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/pdf_annotation_test.cpp b/win-linux/tests/gateway/pdf_annotation_test.cpp new file mode 100644 index 000000000..9db75cb56 --- /dev/null +++ b/win-linux/tests/gateway/pdf_annotation_test.cpp @@ -0,0 +1,183 @@ +// Test cases from gateway-test-case-designs.md §E2 (PDF annotations). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory: schema validation only, round trips deferred to §6. +// pdf.addStamp is intentionally absent -- deferred, not implemented (see +// pdfcommands.cpp's §E2 header comment). + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/pdfcommands.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + QJsonArray SampleRect() + { + return QJsonArray{0, 0, 100, 50}; + } + + bool Test_GetAllAnnots_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.getAllAnnots")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + return spec->validate(scope).isEmpty(); + } + + bool Test_GetAllAnnots_MissingPage_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.getAllAnnots")); + if (!spec) return false; + return !spec->validate(QJsonObject{}).isEmpty(); + } + + bool Test_AddHighlight_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.addHighlight")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + scope.insert(QStringLiteral("rect"), SampleRect()); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddHighlight_MissingRect_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.addHighlight")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + return !spec->validate(scope).isEmpty(); + } + + bool Test_AddHighlight_RectWrongSize_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.addHighlight")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + scope.insert(QStringLiteral("rect"), QJsonArray{0, 0, 100}); + return !spec->validate(scope).isEmpty(); + } + + bool Test_AddUnderline_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.addUnderline")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + scope.insert(QStringLiteral("rect"), SampleRect()); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddStrikeout_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.addStrikeout")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + scope.insert(QStringLiteral("rect"), SampleRect()); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddFreeText_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.addFreeText")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + scope.insert(QStringLiteral("rect"), SampleRect()); + scope.insert(QStringLiteral("text"), QStringLiteral("hello")); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddFreeText_MissingText_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.addFreeText")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + scope.insert(QStringLiteral("rect"), SampleRect()); + return !spec->validate(scope).isEmpty(); + } + + bool Test_AddInk_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.addInk")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + scope.insert(QStringLiteral("rect"), SampleRect()); + QJsonArray path{QJsonArray{10, 10}, QJsonArray{20, 20}, QJsonArray{30, 10}}; + scope.insert(QStringLiteral("paths"), QJsonArray{path}); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddInk_MissingPaths_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.addInk")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + scope.insert(QStringLiteral("rect"), SampleRect()); + return !spec->validate(scope).isEmpty(); + } + + bool Test_AddInk_EmptyPaths_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.addInk")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + scope.insert(QStringLiteral("rect"), SampleRect()); + scope.insert(QStringLiteral("paths"), QJsonArray{}); + return !spec->validate(scope).isEmpty(); + } + + bool Test_AddStamp_NotRegistered() + { + // Deliberately unimplemented -- see pdfcommands.cpp §E2 header comment. + return Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.addStamp")) == nullptr; + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterPdfCommands(); + + const std::vector>> tests = { + {"GetAllAnnots_ValidScope_PassesValidation", Test_GetAllAnnots_ValidScope_PassesValidation}, + {"GetAllAnnots_MissingPage_SchemaInvalid", Test_GetAllAnnots_MissingPage_SchemaInvalid}, + {"AddHighlight_ValidScope_PassesValidation", Test_AddHighlight_ValidScope_PassesValidation}, + {"AddHighlight_MissingRect_SchemaInvalid", Test_AddHighlight_MissingRect_SchemaInvalid}, + {"AddHighlight_RectWrongSize_SchemaInvalid", Test_AddHighlight_RectWrongSize_SchemaInvalid}, + {"AddUnderline_ValidScope_PassesValidation", Test_AddUnderline_ValidScope_PassesValidation}, + {"AddStrikeout_ValidScope_PassesValidation", Test_AddStrikeout_ValidScope_PassesValidation}, + {"AddFreeText_ValidScope_PassesValidation", Test_AddFreeText_ValidScope_PassesValidation}, + {"AddFreeText_MissingText_SchemaInvalid", Test_AddFreeText_MissingText_SchemaInvalid}, + {"AddInk_ValidScope_PassesValidation", Test_AddInk_ValidScope_PassesValidation}, + {"AddInk_MissingPaths_SchemaInvalid", Test_AddInk_MissingPaths_SchemaInvalid}, + {"AddInk_EmptyPaths_SchemaInvalid", Test_AddInk_EmptyPaths_SchemaInvalid}, + {"AddStamp_NotRegistered", Test_AddStamp_NotRegistered}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/pdf_form_field_test.cpp b/win-linux/tests/gateway/pdf_form_field_test.cpp new file mode 100644 index 000000000..0b0be77a0 --- /dev/null +++ b/win-linux/tests/gateway/pdf_form_field_test.cpp @@ -0,0 +1,97 @@ +// Test cases from gateway-test-case-designs.md §E1 (PDF form field read/write). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory: schema validation only, round trips deferred to §6. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/pdfcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_GetAllFields_Registered_NoScopeRequired() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.getAllFields")); + return spec && spec->validate(QJsonObject{}).isEmpty(); + } + + bool Test_GetFieldValue_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.getFieldValue")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("key"), QStringLiteral("Name")); + return spec->validate(scope).isEmpty(); + } + + bool Test_GetFieldValue_EmptyKey_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.getFieldValue")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("key"), QString()); + return !spec->validate(scope).isEmpty(); + } + + bool Test_SetFieldValue_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.setFieldValue")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("key"), QStringLiteral("Name")); + scope.insert(QStringLiteral("value"), QStringLiteral("Alice")); + return spec->validate(scope).isEmpty(); + } + + // E1.3: checkbox "checked" state is a string export value, not a JSON boolean. + bool Test_SetFieldValue_CheckboxStringValue_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.setFieldValue")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("key"), QStringLiteral("Agree")); + scope.insert(QStringLiteral("value"), QStringLiteral("Yes")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetFieldValue_MissingValue_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.setFieldValue")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("key"), QStringLiteral("Name")); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterPdfCommands(); + + const std::vector>> tests = { + {"GetAllFields_Registered_NoScopeRequired", Test_GetAllFields_Registered_NoScopeRequired}, + {"GetFieldValue_ValidScope_PassesValidation", Test_GetFieldValue_ValidScope_PassesValidation}, + {"GetFieldValue_EmptyKey_SchemaInvalid", Test_GetFieldValue_EmptyKey_SchemaInvalid}, + {"SetFieldValue_ValidScope_PassesValidation", Test_SetFieldValue_ValidScope_PassesValidation}, + {"SetFieldValue_CheckboxStringValue_PassesValidation", Test_SetFieldValue_CheckboxStringValue_PassesValidation}, + {"SetFieldValue_MissingValue_SchemaInvalid", Test_SetFieldValue_MissingValue_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/pdf_page_operations_test.cpp b/win-linux/tests/gateway/pdf_page_operations_test.cpp new file mode 100644 index 000000000..6d88b989d --- /dev/null +++ b/win-linux/tests/gateway/pdf_page_operations_test.cpp @@ -0,0 +1,82 @@ +// Test cases from gateway-test-case-designs.md §E5 (PDF page operations). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory: schema validation only, round trips deferred to §6. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/pdfcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_GetPageCount_Registered_NoScopeRequired() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.getPageCount")); + return spec && spec->validate(QJsonObject{}).isEmpty(); + } + + bool Test_AddPage_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.addPage")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 1); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddPage_MissingIndex_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.addPage")); + if (!spec) return false; + return !spec->validate(QJsonObject{}).isEmpty(); + } + + bool Test_RemovePage_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.removePage")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + return spec->validate(scope).isEmpty(); + } + + bool Test_RemovePage_NegativeIndex_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.removePage")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), -1); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterPdfCommands(); + + const std::vector>> tests = { + {"GetPageCount_Registered_NoScopeRequired", Test_GetPageCount_Registered_NoScopeRequired}, + {"AddPage_ValidScope_PassesValidation", Test_AddPage_ValidScope_PassesValidation}, + {"AddPage_MissingIndex_SchemaInvalid", Test_AddPage_MissingIndex_SchemaInvalid}, + {"RemovePage_ValidScope_PassesValidation", Test_RemovePage_ValidScope_PassesValidation}, + {"RemovePage_NegativeIndex_SchemaInvalid", Test_RemovePage_NegativeIndex_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/pdf_redaction_test.cpp b/win-linux/tests/gateway/pdf_redaction_test.cpp new file mode 100644 index 000000000..acf96745c --- /dev/null +++ b/win-linux/tests/gateway/pdf_redaction_test.cpp @@ -0,0 +1,98 @@ +// Test cases from gateway-test-case-designs.md §E4 (PDF redaction). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory: schema validation only, round trips deferred to §6. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/pdfcommands.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_AddRedact_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.addRedact")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + scope.insert(QStringLiteral("rect"), QJsonArray{0, 0, 100, 20}); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddRedact_MissingRect_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.addRedact")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + return !spec->validate(scope).isEmpty(); + } + + bool Test_SearchAndRedact_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.searchAndRedact")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("text"), QStringLiteral("SSN:")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SearchAndRedact_EmptyText_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.searchAndRedact")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("text"), QString()); + return !spec->validate(scope).isEmpty(); + } + + bool Test_SearchAndRedact_NoPageRequired() + { + // Document-wide, unlike pdf.searchText -- no `page` field expected/required. + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.searchAndRedact")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("text"), QStringLiteral("SSN:")); + scope.insert(QStringLiteral("wholeWords"), true); + return spec->validate(scope).isEmpty(); + } + + bool Test_ApplyRedact_NoScopeRequired() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.applyRedact")); + return spec && spec->validate(QJsonObject{}).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterPdfCommands(); + + const std::vector>> tests = { + {"AddRedact_ValidScope_PassesValidation", Test_AddRedact_ValidScope_PassesValidation}, + {"AddRedact_MissingRect_SchemaInvalid", Test_AddRedact_MissingRect_SchemaInvalid}, + {"SearchAndRedact_ValidScope_PassesValidation", Test_SearchAndRedact_ValidScope_PassesValidation}, + {"SearchAndRedact_EmptyText_SchemaInvalid", Test_SearchAndRedact_EmptyText_SchemaInvalid}, + {"SearchAndRedact_NoPageRequired", Test_SearchAndRedact_NoPageRequired}, + {"ApplyRedact_NoScopeRequired", Test_ApplyRedact_NoScopeRequired}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/pdf_text_search_test.cpp b/win-linux/tests/gateway/pdf_text_search_test.cpp new file mode 100644 index 000000000..8ba2585bd --- /dev/null +++ b/win-linux/tests/gateway/pdf_text_search_test.cpp @@ -0,0 +1,165 @@ +// Test cases from gateway-test-case-designs.md §E3 (PDF text search/extraction). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory: schema validation only, round trips deferred to §6. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/pdfcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + QJsonObject SamplePoint(int x, int y) + { + QJsonObject point; + point.insert(QStringLiteral("x"), x); + point.insert(QStringLiteral("y"), y); + return point; + } + + bool Test_SearchText_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.searchText")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + scope.insert(QStringLiteral("text"), QStringLiteral("Total")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SearchText_OptionalFlags_PassValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.searchText")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + scope.insert(QStringLiteral("text"), QStringLiteral("Total")); + scope.insert(QStringLiteral("matchCase"), true); + scope.insert(QStringLiteral("wholeWords"), false); + return spec->validate(scope).isEmpty(); + } + + bool Test_SearchText_EmptyText_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.searchText")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + scope.insert(QStringLiteral("text"), QString()); + return !spec->validate(scope).isEmpty(); + } + + bool Test_SearchText_MatchCaseWrongType_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.searchText")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + scope.insert(QStringLiteral("text"), QStringLiteral("Total")); + scope.insert(QStringLiteral("matchCase"), QStringLiteral("yes")); + return !spec->validate(scope).isEmpty(); + } + + bool Test_SetSelection_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.setSelection")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + scope.insert(QStringLiteral("startPoint"), SamplePoint(10, 10)); + scope.insert(QStringLiteral("endPoint"), SamplePoint(50, 20)); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetSelection_MissingEndPoint_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.setSelection")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + scope.insert(QStringLiteral("startPoint"), SamplePoint(10, 10)); + return !spec->validate(scope).isEmpty(); + } + + bool Test_SetSelection_PointMissingY_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.setSelection")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + QJsonObject badPoint; + badPoint.insert(QStringLiteral("x"), 10); + scope.insert(QStringLiteral("startPoint"), badPoint); + scope.insert(QStringLiteral("endPoint"), SamplePoint(50, 20)); + return !spec->validate(scope).isEmpty(); + } + + bool Test_GetSelectedText_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.getSelectedText")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + return spec->validate(scope).isEmpty(); + } + + bool Test_GetSelectedText_MissingPage_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.getSelectedText")); + if (!spec) return false; + return !spec->validate(QJsonObject{}).isEmpty(); + } + + bool Test_RecognizeContent_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.recognizeContent")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("page"), 0); + return spec->validate(scope).isEmpty(); + } + + bool Test_RecognizeContent_MissingPage_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("pdf.recognizeContent")); + if (!spec) return false; + return !spec->validate(QJsonObject{}).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterPdfCommands(); + + const std::vector>> tests = { + {"SearchText_ValidScope_PassesValidation", Test_SearchText_ValidScope_PassesValidation}, + {"SearchText_OptionalFlags_PassValidation", Test_SearchText_OptionalFlags_PassValidation}, + {"SearchText_EmptyText_SchemaInvalid", Test_SearchText_EmptyText_SchemaInvalid}, + {"SearchText_MatchCaseWrongType_SchemaInvalid", Test_SearchText_MatchCaseWrongType_SchemaInvalid}, + {"SetSelection_ValidScope_PassesValidation", Test_SetSelection_ValidScope_PassesValidation}, + {"SetSelection_MissingEndPoint_SchemaInvalid", Test_SetSelection_MissingEndPoint_SchemaInvalid}, + {"SetSelection_PointMissingY_SchemaInvalid", Test_SetSelection_PointMissingY_SchemaInvalid}, + {"GetSelectedText_ValidScope_PassesValidation", Test_GetSelectedText_ValidScope_PassesValidation}, + {"GetSelectedText_MissingPage_SchemaInvalid", Test_GetSelectedText_MissingPage_SchemaInvalid}, + {"RecognizeContent_ValidScope_PassesValidation", Test_RecognizeContent_ValidScope_PassesValidation}, + {"RecognizeContent_MissingPage_SchemaInvalid", Test_RecognizeContent_MissingPage_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/slide_comment_test.cpp b/win-linux/tests/gateway/slide_comment_test.cpp new file mode 100644 index 000000000..9de52af88 --- /dev/null +++ b/win-linux/tests/gateway/slide_comment_test.cpp @@ -0,0 +1,70 @@ +// Test cases from gateway-test-case-designs.md §D10 (Slide comments). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/slidecommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_AddComment_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.addComment")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + scope.insert(QStringLiteral("x"), 0); + scope.insert(QStringLiteral("y"), 0); + scope.insert(QStringLiteral("text"), QStringLiteral("fix typo")); + scope.insert(QStringLiteral("author"), QStringLiteral("peter")); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddComment_MissingY_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.addComment")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + scope.insert(QStringLiteral("x"), 0); + scope.insert(QStringLiteral("text"), QStringLiteral("fix typo")); + return !spec->validate(scope).isEmpty(); + } + + bool Test_GetAllComments_Registered_NoScopeRequired() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("presentation.getAllComments")); + return spec && spec->validate(QJsonObject{}).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterSlideCommands(); + + const std::vector>> tests = { + {"AddComment_ValidScope_PassesValidation", Test_AddComment_ValidScope_PassesValidation}, + {"AddComment_MissingY_SchemaInvalid", Test_AddComment_MissingY_SchemaInvalid}, + {"GetAllComments_Registered_NoScopeRequired", Test_GetAllComments_Registered_NoScopeRequired}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/slide_content_enumeration_test.cpp b/win-linux/tests/gateway/slide_content_enumeration_test.cpp new file mode 100644 index 000000000..7e0a33351 --- /dev/null +++ b/win-linux/tests/gateway/slide_content_enumeration_test.cpp @@ -0,0 +1,75 @@ +// Test cases from gateway-test-case-designs.md §D2 (Slide enumerate content). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/slidecommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_GetAllShapes_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.getAllShapes")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + return spec->validate(scope).isEmpty(); + } + + bool Test_GetAllImages_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.getAllImages")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + return spec->validate(scope).isEmpty(); + } + + bool Test_GetAllTables_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.getAllTables")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + return spec->validate(scope).isEmpty(); + } + + bool Test_GetAllCharts_MissingIndex_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.getAllCharts")); + if (!spec) return false; + return !spec->validate(QJsonObject{}).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterSlideCommands(); + + const std::vector>> tests = { + {"GetAllShapes_ValidScope_PassesValidation", Test_GetAllShapes_ValidScope_PassesValidation}, + {"GetAllImages_ValidScope_PassesValidation", Test_GetAllImages_ValidScope_PassesValidation}, + {"GetAllTables_ValidScope_PassesValidation", Test_GetAllTables_ValidScope_PassesValidation}, + {"GetAllCharts_MissingIndex_SchemaInvalid", Test_GetAllCharts_MissingIndex_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/slide_document_properties_test.cpp b/win-linux/tests/gateway/slide_document_properties_test.cpp new file mode 100644 index 000000000..a54b22fd1 --- /dev/null +++ b/win-linux/tests/gateway/slide_document_properties_test.cpp @@ -0,0 +1,65 @@ +// Test cases from gateway-test-case-designs.md §D11 (Slide document properties). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory. This is the last of the 11 Slide command families per +// cdp-gateway-cli-plan.md §4. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/slidecommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_GetDocumentInfo_Registered_NoScopeRequired() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("presentation.getDocumentInfo")); + return spec && spec->validate(QJsonObject{}).isEmpty(); + } + + bool Test_GetCustomProperty_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("presentation.getCustomProperty")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("name"), QStringLiteral("Reviewed")); + return spec->validate(scope).isEmpty(); + } + + bool Test_GetCustomProperty_EmptyName_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("presentation.getCustomProperty")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("name"), QString()); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterSlideCommands(); + + const std::vector>> tests = { + {"GetDocumentInfo_Registered_NoScopeRequired", Test_GetDocumentInfo_Registered_NoScopeRequired}, + {"GetCustomProperty_ValidScope_PassesValidation", Test_GetCustomProperty_ValidScope_PassesValidation}, + {"GetCustomProperty_EmptyName_SchemaInvalid", Test_GetCustomProperty_EmptyName_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/slide_image_test.cpp b/win-linux/tests/gateway/slide_image_test.cpp new file mode 100644 index 000000000..79d26e142 --- /dev/null +++ b/win-linux/tests/gateway/slide_image_test.cpp @@ -0,0 +1,66 @@ +// Test cases from gateway-test-case-designs.md §D7 (Slide insert images). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/slidecommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_CreateImage_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.createImage")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + scope.insert(QStringLiteral("imageSrc"), QStringLiteral("data:image/png;base64,AAAA")); + scope.insert(QStringLiteral("x"), 0); + scope.insert(QStringLiteral("y"), 0); + scope.insert(QStringLiteral("width"), 914400); + scope.insert(QStringLiteral("height"), 914400); + return spec->validate(scope).isEmpty(); + } + + bool Test_CreateImage_MissingImageSrc_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.createImage")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + scope.insert(QStringLiteral("x"), 0); + scope.insert(QStringLiteral("y"), 0); + scope.insert(QStringLiteral("width"), 914400); + scope.insert(QStringLiteral("height"), 914400); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterSlideCommands(); + + const std::vector>> tests = { + {"CreateImage_ValidScope_PassesValidation", Test_CreateImage_ValidScope_PassesValidation}, + {"CreateImage_MissingImageSrc_SchemaInvalid", Test_CreateImage_MissingImageSrc_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/slide_layout_master_test.cpp b/win-linux/tests/gateway/slide_layout_master_test.cpp new file mode 100644 index 000000000..ac69e8ecd --- /dev/null +++ b/win-linux/tests/gateway/slide_layout_master_test.cpp @@ -0,0 +1,86 @@ +// Test cases from gateway-test-case-designs.md §D3 (Slide layouts/masters; theme +// application deferred, see that section's header note). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/slidecommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_GetLayout_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.getLayout")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + return spec->validate(scope).isEmpty(); + } + + bool Test_ApplyLayout_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.applyLayout")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + scope.insert(QStringLiteral("fromIndex"), 1); + return spec->validate(scope).isEmpty(); + } + + bool Test_ApplyLayout_MissingFromIndex_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.applyLayout")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + return !spec->validate(scope).isEmpty(); + } + + bool Test_AddMaster_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.addMaster")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("position"), 1); + return spec->validate(scope).isEmpty(); + } + + // §D3: slide.applyTheme is deliberately not registered. + bool Test_ApplyTheme_NotImplemented_NotAllowlisted() + { + return Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.applyTheme")) == nullptr; + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterSlideCommands(); + + const std::vector>> tests = { + {"GetLayout_ValidScope_PassesValidation", Test_GetLayout_ValidScope_PassesValidation}, + {"ApplyLayout_ValidScope_PassesValidation", Test_ApplyLayout_ValidScope_PassesValidation}, + {"ApplyLayout_MissingFromIndex_SchemaInvalid", Test_ApplyLayout_MissingFromIndex_SchemaInvalid}, + {"AddMaster_ValidScope_PassesValidation", Test_AddMaster_ValidScope_PassesValidation}, + {"ApplyTheme_NotImplemented_NotAllowlisted", Test_ApplyTheme_NotImplemented_NotAllowlisted}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/slide_management_test.cpp b/win-linux/tests/gateway/slide_management_test.cpp new file mode 100644 index 000000000..3af23f62a --- /dev/null +++ b/win-linux/tests/gateway/slide_management_test.cpp @@ -0,0 +1,100 @@ +// Test cases from gateway-test-case-designs.md §D1 (Slide management). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory: schema validation only, round trips deferred to §6. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/slidecommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_AddSlide_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.addSlide")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 1); + return spec->validate(scope).isEmpty(); + } + + bool Test_RemoveSlides_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.removeSlides")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("start"), 1); + scope.insert(QStringLiteral("count"), 1); + return spec->validate(scope).isEmpty(); + } + + bool Test_RemoveSlides_ZeroCount_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.removeSlides")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("start"), 1); + scope.insert(QStringLiteral("count"), 0); + return !spec->validate(scope).isEmpty(); + } + + bool Test_Duplicate_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.duplicate")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + return spec->validate(scope).isEmpty(); + } + + bool Test_MoveTo_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.moveTo")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + scope.insert(QStringLiteral("newIndex"), 2); + return spec->validate(scope).isEmpty(); + } + + bool Test_MoveTo_MissingNewIndex_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.moveTo")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterSlideCommands(); + + const std::vector>> tests = { + {"AddSlide_ValidScope_PassesValidation", Test_AddSlide_ValidScope_PassesValidation}, + {"RemoveSlides_ValidScope_PassesValidation", Test_RemoveSlides_ValidScope_PassesValidation}, + {"RemoveSlides_ZeroCount_SchemaInvalid", Test_RemoveSlides_ZeroCount_SchemaInvalid}, + {"Duplicate_ValidScope_PassesValidation", Test_Duplicate_ValidScope_PassesValidation}, + {"MoveTo_ValidScope_PassesValidation", Test_MoveTo_ValidScope_PassesValidation}, + {"MoveTo_MissingNewIndex_SchemaInvalid", Test_MoveTo_MissingNewIndex_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/slide_notes_test.cpp b/win-linux/tests/gateway/slide_notes_test.cpp new file mode 100644 index 000000000..a5bdb4eee --- /dev/null +++ b/win-linux/tests/gateway/slide_notes_test.cpp @@ -0,0 +1,69 @@ +// Test cases from gateway-test-case-designs.md §D9 (Slide speaker notes). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/slidecommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_AddNotesText_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.addNotesText")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + scope.insert(QStringLiteral("text"), QStringLiteral("Remember to mention Q3")); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddNotesText_EmptyText_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.addNotesText")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + scope.insert(QStringLiteral("text"), QString()); + return !spec->validate(scope).isEmpty(); + } + + bool Test_GetNotesText_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.getNotesText")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + return spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterSlideCommands(); + + const std::vector>> tests = { + {"AddNotesText_ValidScope_PassesValidation", Test_AddNotesText_ValidScope_PassesValidation}, + {"AddNotesText_EmptyText_SchemaInvalid", Test_AddNotesText_EmptyText_SchemaInvalid}, + {"GetNotesText_ValidScope_PassesValidation", Test_GetNotesText_ValidScope_PassesValidation}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/slide_shape_test.cpp b/win-linux/tests/gateway/slide_shape_test.cpp new file mode 100644 index 000000000..202e051b9 --- /dev/null +++ b/win-linux/tests/gateway/slide_shape_test.cpp @@ -0,0 +1,104 @@ +// Test cases from gateway-test-case-designs.md §D5 (Slide insert shapes with +// positioning). See word_document_properties_test.cpp's header comment for +// scope/limits shared by every file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/slidecommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_CreateShape_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.createShape")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + scope.insert(QStringLiteral("type"), QStringLiteral("rect")); + scope.insert(QStringLiteral("x"), 10); + scope.insert(QStringLiteral("y"), 10); + scope.insert(QStringLiteral("width"), 100); + scope.insert(QStringLiteral("height"), 50); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetPosition_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.setPosition")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + scope.insert(QStringLiteral("shapeIndex"), 0); + scope.insert(QStringLiteral("x"), 200); + scope.insert(QStringLiteral("y"), 200); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetRotation_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.setRotation")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + scope.insert(QStringLiteral("shapeIndex"), 0); + scope.insert(QStringLiteral("degrees"), 45); + return spec->validate(scope).isEmpty(); + } + + // D5.4 + bool Test_SetSize_NegativeWidth_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.setSize")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + scope.insert(QStringLiteral("shapeIndex"), 0); + scope.insert(QStringLiteral("width"), -10); + scope.insert(QStringLiteral("height"), 50); + return !spec->validate(scope).isEmpty(); + } + + bool Test_SetSize_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.setSize")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + scope.insert(QStringLiteral("shapeIndex"), 0); + scope.insert(QStringLiteral("width"), 100); + scope.insert(QStringLiteral("height"), 50); + return spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterSlideCommands(); + + const std::vector>> tests = { + {"CreateShape_ValidScope_PassesValidation", Test_CreateShape_ValidScope_PassesValidation}, + {"SetPosition_ValidScope_PassesValidation", Test_SetPosition_ValidScope_PassesValidation}, + {"SetRotation_ValidScope_PassesValidation", Test_SetRotation_ValidScope_PassesValidation}, + {"SetSize_NegativeWidth_SchemaInvalid", Test_SetSize_NegativeWidth_SchemaInvalid}, + {"SetSize_ValidScope_PassesValidation", Test_SetSize_ValidScope_PassesValidation}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/slide_table_test.cpp b/win-linux/tests/gateway/slide_table_test.cpp new file mode 100644 index 000000000..0704a516d --- /dev/null +++ b/win-linux/tests/gateway/slide_table_test.cpp @@ -0,0 +1,85 @@ +// Test cases from gateway-test-case-designs.md §D8 (Slide table editing; creation +// deferred, see that section's header note). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/slidecommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_AddRow_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.addRow")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + scope.insert(QStringLiteral("tableIndex"), 0); + return spec->validate(scope).isEmpty(); + } + + bool Test_MergeCells_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.mergeCells")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + scope.insert(QStringLiteral("tableIndex"), 0); + scope.insert(QStringLiteral("fromRow"), 0); + scope.insert(QStringLiteral("fromCol"), 0); + scope.insert(QStringLiteral("toRow"), 0); + scope.insert(QStringLiteral("toCol"), 1); + return spec->validate(scope).isEmpty(); + } + + bool Test_MergeCells_MissingToCol_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.mergeCells")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + scope.insert(QStringLiteral("tableIndex"), 0); + scope.insert(QStringLiteral("fromRow"), 0); + scope.insert(QStringLiteral("fromCol"), 0); + scope.insert(QStringLiteral("toRow"), 0); + return !spec->validate(scope).isEmpty(); + } + + // §D8: slide.createTable is deliberately not registered. + bool Test_CreateTable_NotImplemented_NotAllowlisted() + { + return Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.createTable")) == nullptr; + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterSlideCommands(); + + const std::vector>> tests = { + {"AddRow_ValidScope_PassesValidation", Test_AddRow_ValidScope_PassesValidation}, + {"MergeCells_ValidScope_PassesValidation", Test_MergeCells_ValidScope_PassesValidation}, + {"MergeCells_MissingToCol_SchemaInvalid", Test_MergeCells_MissingToCol_SchemaInvalid}, + {"CreateTable_NotImplemented_NotAllowlisted", Test_CreateTable_NotImplemented_NotAllowlisted}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/slide_text_formatting_test.cpp b/win-linux/tests/gateway/slide_text_formatting_test.cpp new file mode 100644 index 000000000..ffc695d5f --- /dev/null +++ b/win-linux/tests/gateway/slide_text_formatting_test.cpp @@ -0,0 +1,90 @@ +// Test cases from gateway-test-case-designs.md §D6 (Slide text formatting). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/slidecommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + QJsonObject RunTarget() + { + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + scope.insert(QStringLiteral("shapeIndex"), 0); + scope.insert(QStringLiteral("paraIndex"), 0); + scope.insert(QStringLiteral("runIndex"), 0); + return scope; + } + + bool Test_SetBold_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.setBold")); + if (!spec) return false; + QJsonObject scope = RunTarget(); + scope.insert(QStringLiteral("bold"), true); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetBold_MissingParaIndex_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.setBold")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + scope.insert(QStringLiteral("shapeIndex"), 0); + scope.insert(QStringLiteral("runIndex"), 0); + scope.insert(QStringLiteral("bold"), true); + return !spec->validate(scope).isEmpty(); + } + + bool Test_SetFontFamily_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.setFontFamily")); + if (!spec) return false; + QJsonObject scope = RunTarget(); + scope.insert(QStringLiteral("font"), QStringLiteral("Georgia")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetFontFamily_EmptyFont_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.setFontFamily")); + if (!spec) return false; + QJsonObject scope = RunTarget(); + scope.insert(QStringLiteral("font"), QString()); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterSlideCommands(); + + const std::vector>> tests = { + {"SetBold_ValidScope_PassesValidation", Test_SetBold_ValidScope_PassesValidation}, + {"SetBold_MissingParaIndex_SchemaInvalid", Test_SetBold_MissingParaIndex_SchemaInvalid}, + {"SetFontFamily_ValidScope_PassesValidation", Test_SetFontFamily_ValidScope_PassesValidation}, + {"SetFontFamily_EmptyFont_SchemaInvalid", Test_SetFontFamily_EmptyFont_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/slide_transition_test.cpp b/win-linux/tests/gateway/slide_transition_test.cpp new file mode 100644 index 000000000..7de0b7a92 --- /dev/null +++ b/win-linux/tests/gateway/slide_transition_test.cpp @@ -0,0 +1,68 @@ +// Test cases from gateway-test-case-designs.md §D4 (Slide transitions; background +// deferred, see that section's header note). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/slidecommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_SetTransition_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.setTransition")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + scope.insert(QStringLiteral("entryEffect"), QStringLiteral("effectFade")); + scope.insert(QStringLiteral("duration"), 500); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetTransition_MissingEntryEffect_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.setTransition")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("index"), 0); + scope.insert(QStringLiteral("duration"), 500); + return !spec->validate(scope).isEmpty(); + } + + // §D4: slide.setBackground is deliberately not registered. + bool Test_SetBackground_NotImplemented_NotAllowlisted() + { + return Gateway::AllowlistTable::Instance().Find(QStringLiteral("slide.setBackground")) == nullptr; + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterSlideCommands(); + + const std::vector>> tests = { + {"SetTransition_ValidScope_PassesValidation", Test_SetTransition_ValidScope_PassesValidation}, + {"SetTransition_MissingEntryEffect_SchemaInvalid", Test_SetTransition_MissingEntryEffect_SchemaInvalid}, + {"SetBackground_NotImplemented_NotAllowlisted", Test_SetBackground_NotImplemented_NotAllowlisted}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/word_bookmark_hyperlink_test.cpp b/win-linux/tests/gateway/word_bookmark_hyperlink_test.cpp new file mode 100644 index 000000000..c81ac07a8 --- /dev/null +++ b/win-linux/tests/gateway/word_bookmark_hyperlink_test.cpp @@ -0,0 +1,107 @@ +// Test cases from gateway-test-case-designs.md §B11 (Word bookmarks and hyperlinks). +// See word_document_properties_test.cpp's header comment for scope/limits shared by +// every file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/wordcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_AddBookmark_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.addBookmark")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), 0); + scope.insert(QStringLiteral("name"), QStringLiteral("section1")); + return spec->validate(scope).isEmpty(); + } + + bool Test_GetBookmark_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.getBookmark")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("name"), QStringLiteral("section1")); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddHyperlink_HttpsUrl_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.addHyperlink")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), 0); + scope.insert(QStringLiteral("text"), QStringLiteral("link")); + scope.insert(QStringLiteral("url"), QStringLiteral("https://example.com")); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddHyperlink_MailtoUrl_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.addHyperlink")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), 0); + scope.insert(QStringLiteral("text"), QStringLiteral("mail me")); + scope.insert(QStringLiteral("url"), QStringLiteral("mailto:someone@example.com")); + return spec->validate(scope).isEmpty(); + } + + // B11.3: javascript: scheme is rejected as a security boundary. + bool Test_AddHyperlink_JavascriptScheme_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.addHyperlink")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), 0); + scope.insert(QStringLiteral("text"), QStringLiteral("link")); + scope.insert(QStringLiteral("url"), QStringLiteral("javascript:alert(1)")); + return !spec->validate(scope).isEmpty(); + } + + bool Test_AddHyperlink_FileScheme_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.addHyperlink")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), 0); + scope.insert(QStringLiteral("text"), QStringLiteral("link")); + scope.insert(QStringLiteral("url"), QStringLiteral("file:///etc/passwd")); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterWordCommands(); + + const std::vector>> tests = { + {"AddBookmark_ValidScope_PassesValidation", Test_AddBookmark_ValidScope_PassesValidation}, + {"GetBookmark_ValidScope_PassesValidation", Test_GetBookmark_ValidScope_PassesValidation}, + {"AddHyperlink_HttpsUrl_PassesValidation", Test_AddHyperlink_HttpsUrl_PassesValidation}, + {"AddHyperlink_MailtoUrl_PassesValidation", Test_AddHyperlink_MailtoUrl_PassesValidation}, + {"AddHyperlink_JavascriptScheme_SchemaInvalid", Test_AddHyperlink_JavascriptScheme_SchemaInvalid}, + {"AddHyperlink_FileScheme_SchemaInvalid", Test_AddHyperlink_FileScheme_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/word_character_formatting_test.cpp b/win-linux/tests/gateway/word_character_formatting_test.cpp new file mode 100644 index 000000000..3e6a73583 --- /dev/null +++ b/win-linux/tests/gateway/word_character_formatting_test.cpp @@ -0,0 +1,118 @@ +// Test cases from gateway-test-case-designs.md §B4 (Word character formatting). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/wordcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + QJsonObject RunTarget() + { + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), 0); + scope.insert(QStringLiteral("runIndex"), 0); + return scope; + } + + bool Test_SetBold_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setBold")); + if (!spec) return false; + QJsonObject scope = RunTarget(); + scope.insert(QStringLiteral("bold"), true); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetBold_NonBoolBold_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setBold")); + if (!spec) return false; + QJsonObject scope = RunTarget(); + scope.insert(QStringLiteral("bold"), QStringLiteral("yes")); + return !spec->validate(scope).isEmpty(); + } + + bool Test_SetItalic_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setItalic")); + if (!spec) return false; + QJsonObject scope = RunTarget(); + scope.insert(QStringLiteral("italic"), true); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetFontFamily_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setFontFamily")); + if (!spec) return false; + QJsonObject scope = RunTarget(); + scope.insert(QStringLiteral("font"), QStringLiteral("Arial")); + return spec->validate(scope).isEmpty(); + } + + // B4.4: a nonexistent font name is still schema-valid -- font substitution is a + // rendering concern, not a gateway validation concern. + bool Test_SetFontFamily_UnknownFontName_StillValid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setFontFamily")); + if (!spec) return false; + QJsonObject scope = RunTarget(); + scope.insert(QStringLiteral("font"), QStringLiteral("NotARealFontXYZ")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetColor_ValidHex_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setColor")); + if (!spec) return false; + QJsonObject scope = RunTarget(); + scope.insert(QStringLiteral("color"), QStringLiteral("#FF0000")); + return spec->validate(scope).isEmpty(); + } + + // B4.6 + bool Test_SetColor_InvalidColorString_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setColor")); + if (!spec) return false; + QJsonObject scope = RunTarget(); + scope.insert(QStringLiteral("color"), QStringLiteral("not-a-color")); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterWordCommands(); + + const std::vector>> tests = { + {"SetBold_ValidScope_PassesValidation", Test_SetBold_ValidScope_PassesValidation}, + {"SetBold_NonBoolBold_SchemaInvalid", Test_SetBold_NonBoolBold_SchemaInvalid}, + {"SetItalic_ValidScope_PassesValidation", Test_SetItalic_ValidScope_PassesValidation}, + {"SetFontFamily_ValidScope_PassesValidation", Test_SetFontFamily_ValidScope_PassesValidation}, + {"SetFontFamily_UnknownFontName_StillValid", Test_SetFontFamily_UnknownFontName_StillValid}, + {"SetColor_ValidHex_PassesValidation", Test_SetColor_ValidHex_PassesValidation}, + {"SetColor_InvalidColorString_SchemaInvalid", Test_SetColor_InvalidColorString_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/word_comment_revision_test.cpp b/win-linux/tests/gateway/word_comment_revision_test.cpp new file mode 100644 index 000000000..209e5a948 --- /dev/null +++ b/win-linux/tests/gateway/word_comment_revision_test.cpp @@ -0,0 +1,93 @@ +// Test cases from gateway-test-case-designs.md §B13 (Word comments and track +// changes). See word_document_properties_test.cpp's header comment for scope/limits +// shared by every file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/wordcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_AddComment_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.addComment")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), 0); + scope.insert(QStringLiteral("text"), QStringLiteral("needs review")); + scope.insert(QStringLiteral("author"), QStringLiteral("peter")); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddComment_EmptyText_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.addComment")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), 0); + scope.insert(QStringLiteral("text"), QString()); + scope.insert(QStringLiteral("author"), QStringLiteral("peter")); + return !spec->validate(scope).isEmpty(); + } + + bool Test_GetAllComments_Registered_NoScopeRequired() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.getAllComments")); + return spec && spec->validate(QJsonObject{}).isEmpty(); + } + + bool Test_SetTrackRevisions_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setTrackRevisions")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("enabled"), true); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetTrackRevisions_MissingEnabled_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setTrackRevisions")); + if (!spec) return false; + return !spec->validate(QJsonObject{}).isEmpty(); + } + + bool Test_AcceptAllRevisionChanges_Registered_NoScopeRequired() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.acceptAllRevisionChanges")); + return spec && spec->validate(QJsonObject{}).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterWordCommands(); + + const std::vector>> tests = { + {"AddComment_ValidScope_PassesValidation", Test_AddComment_ValidScope_PassesValidation}, + {"AddComment_EmptyText_SchemaInvalid", Test_AddComment_EmptyText_SchemaInvalid}, + {"GetAllComments_Registered_NoScopeRequired", Test_GetAllComments_Registered_NoScopeRequired}, + {"SetTrackRevisions_ValidScope_PassesValidation", Test_SetTrackRevisions_ValidScope_PassesValidation}, + {"SetTrackRevisions_MissingEnabled_SchemaInvalid", Test_SetTrackRevisions_MissingEnabled_SchemaInvalid}, + {"AcceptAllRevisionChanges_Registered_NoScopeRequired", Test_AcceptAllRevisionChanges_Registered_NoScopeRequired}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/word_content_enumeration_test.cpp b/win-linux/tests/gateway/word_content_enumeration_test.cpp new file mode 100644 index 000000000..583c0b05e --- /dev/null +++ b/win-linux/tests/gateway/word_content_enumeration_test.cpp @@ -0,0 +1,69 @@ +// Test cases from gateway-test-case-designs.md §B2 (Word content enumeration), +// implemented against AllowlistTable/CommandSpec directly. See +// word_document_properties_test.cpp's header comment for what this harness can and +// cannot verify without a live CCefView -- the same limits apply here: only allowlist +// registration and scope validation are checked; the round-trip cases (does +// word.getAllParagraphs actually return [0,1,2] against a 3-paragraph document) are +// exercised at the plan's §6 per-editor build/deploy/test gate instead. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/wordcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_GetAllParagraphs_Registered_NoScopeRequired() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.getAllParagraphs")); + return spec && spec->validate(QJsonObject{}).isEmpty(); + } + + bool Test_GetAllTables_Registered_NoScopeRequired() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.getAllTables")); + return spec && spec->validate(QJsonObject{}).isEmpty(); + } + + bool Test_GetAllDrawingObjects_Registered_NoScopeRequired() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.getAllDrawingObjects")); + return spec && spec->validate(QJsonObject{}).isEmpty(); + } + + bool Test_GetAllCharts_Registered_NoScopeRequired() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.getAllCharts")); + return spec && spec->validate(QJsonObject{}).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterWordCommands(); + + const std::vector>> tests = { + {"GetAllParagraphs_Registered_NoScopeRequired", Test_GetAllParagraphs_Registered_NoScopeRequired}, + {"GetAllTables_Registered_NoScopeRequired", Test_GetAllTables_Registered_NoScopeRequired}, + {"GetAllDrawingObjects_Registered_NoScopeRequired", Test_GetAllDrawingObjects_Registered_NoScopeRequired}, + {"GetAllCharts_Registered_NoScopeRequired", Test_GetAllCharts_Registered_NoScopeRequired}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/word_document_properties_test.cpp b/win-linux/tests/gateway/word_document_properties_test.cpp new file mode 100644 index 000000000..16dead5c2 --- /dev/null +++ b/win-linux/tests/gateway/word_document_properties_test.cpp @@ -0,0 +1,124 @@ +// Test cases from gateway-test-case-designs.md §A (cross-cutting dispatch) and §B1 +// (Word document properties), implemented against AllowlistTable/CommandSpec directly. +// +// SCOPE OF WHAT THIS FILE CAN VERIFY RIGHT NOW: schema validation and allowlist lookup +// only (A1-A4, B1.2 negative half, B1.4). The cases that require a real CDP round trip +// against a running document (A5-A9, B1.1, B1.3, B1.5) are NOT executable from this +// standalone binary — GatewayCommandRunner now resolves targets and drives CDP for +// real (CAscApplicationManager::GetViewById + CCefView::SendGatewayDevToolsMessage, +// see gatewaycommandrunner.cpp), but that needs an actual running CCefView backed by +// a live CEF browser, which this lightweight CTest target deliberately does not spin +// up (would mean linking the whole ascdocumentscore/CEF stack into a "unit" test). +// Those round-trip cases are exercised instead at the per-editor gate +// (cdp-gateway-cli-plan.md §6): build the real DesktopEditors binary, run it, and +// drive these same commands through it end-to-end -- that's what "verify: test +// passes... against a running editor instance" (§5 step 4) actually means for this +// command family. +// +// No test framework dependency was added for this (repo has none currently - see +// investigation notes referenced from the plan); this is a minimal self-contained +// harness: each Test_* function returns true/false, main() aggregates and reports. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/wordcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_A1_UnknownCommand_NotAllowlisted() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("not.a.real.command")); + return spec == nullptr; + } + + bool Test_A2_MissingRequiredField_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setTitle")); + if (!spec) return false; + return !spec->validate(QJsonObject{}).isEmpty(); // "title" missing -> non-empty error string + } + + bool Test_A3_WrongFieldType_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setCustomProperty")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("name"), 42); // should be a string + scope.insert(QStringLiteral("value"), QStringLiteral("x")); + return !spec->validate(scope).isEmpty(); + } + + // B1.1's positive half (setTitle/getTitle round trip against a live document) is + // blocked on the target-resolution gap — see file header. This only verifies the + // command accepts a well-formed scope at the validation layer. + bool Test_B1_1_SetTitle_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setTitle")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("title"), QStringLiteral("Q3 Report")); + return spec->validate(scope).isEmpty(); + } + + bool Test_B1_2_SetTitle_EmptyString_Accepted() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setTitle")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("title"), QString()); + return spec->validate(scope).isEmpty(); // empty string is allowed per B1.2 + } + + bool Test_B1_4_SetCustomProperty_EmptyName_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setCustomProperty")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("name"), QString()); + scope.insert(QStringLiteral("value"), QStringLiteral("x")); + return !spec->validate(scope).isEmpty(); // empty name rejected, per corrected B1.4 + } + + bool Test_GetCustomProperty_RequiresName() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.getCustomProperty")); + if (!spec) return false; + return !spec->validate(QJsonObject{}).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); // QJsonObject/QString need a QCoreApplication-less + // runtime technically, but keeping this consistent + // with how the rest of the app initializes Qt types. + + Gateway::Commands::RegisterWordCommands(); + + const std::vector>> tests = { + {"A1_UnknownCommand_NotAllowlisted", Test_A1_UnknownCommand_NotAllowlisted}, + {"A2_MissingRequiredField_SchemaInvalid", Test_A2_MissingRequiredField_SchemaInvalid}, + {"A3_WrongFieldType_SchemaInvalid", Test_A3_WrongFieldType_SchemaInvalid}, + {"B1_1_SetTitle_ValidScope_PassesValidation", Test_B1_1_SetTitle_ValidScope_PassesValidation}, + {"B1_2_SetTitle_EmptyString_Accepted", Test_B1_2_SetTitle_EmptyString_Accepted}, + {"B1_4_SetCustomProperty_EmptyName_SchemaInvalid", Test_B1_4_SetCustomProperty_EmptyName_SchemaInvalid}, + {"GetCustomProperty_RequiresName", Test_GetCustomProperty_RequiresName}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/word_form_field_test.cpp b/win-linux/tests/gateway/word_form_field_test.cpp new file mode 100644 index 000000000..16d123df5 --- /dev/null +++ b/win-linux/tests/gateway/word_form_field_test.cpp @@ -0,0 +1,96 @@ +// Test cases from gateway-test-case-designs.md §B12 (Word fillable form fields). +// word.addCheckBoxForm is deliberately not implemented -- see that section's header +// note for why. See word_document_properties_test.cpp's header comment for +// scope/limits shared by every file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/wordcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_AddTextForm_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.addTextForm")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), 0); + scope.insert(QStringLiteral("key"), QStringLiteral("name")); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddTextForm_EmptyKey_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.addTextForm")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), 0); + scope.insert(QStringLiteral("key"), QString()); + return !spec->validate(scope).isEmpty(); + } + + bool Test_GetAllForms_Registered_NoScopeRequired() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.getAllForms")); + return spec && spec->validate(QJsonObject{}).isEmpty(); + } + + bool Test_SetFormsData_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setFormsData")); + if (!spec) return false; + QJsonObject data; + data.insert(QStringLiteral("name"), QStringLiteral("Alice")); + QJsonObject scope; + scope.insert(QStringLiteral("data"), data); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetFormsData_NonObjectData_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setFormsData")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("data"), QStringLiteral("not-an-object")); + return !spec->validate(scope).isEmpty(); + } + + // B12.4: word.addCheckBoxForm is intentionally not registered. + bool Test_AddCheckBoxForm_NotImplemented_NotAllowlisted() + { + return Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.addCheckBoxForm")) == nullptr; + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterWordCommands(); + + const std::vector>> tests = { + {"AddTextForm_ValidScope_PassesValidation", Test_AddTextForm_ValidScope_PassesValidation}, + {"AddTextForm_EmptyKey_SchemaInvalid", Test_AddTextForm_EmptyKey_SchemaInvalid}, + {"GetAllForms_Registered_NoScopeRequired", Test_GetAllForms_Registered_NoScopeRequired}, + {"SetFormsData_ValidScope_PassesValidation", Test_SetFormsData_ValidScope_PassesValidation}, + {"SetFormsData_NonObjectData_SchemaInvalid", Test_SetFormsData_NonObjectData_SchemaInvalid}, + {"AddCheckBoxForm_NotImplemented_NotAllowlisted", Test_AddCheckBoxForm_NotImplemented_NotAllowlisted}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/word_image_shape_test.cpp b/win-linux/tests/gateway/word_image_shape_test.cpp new file mode 100644 index 000000000..896043c99 --- /dev/null +++ b/win-linux/tests/gateway/word_image_shape_test.cpp @@ -0,0 +1,109 @@ +// Test cases from gateway-test-case-designs.md §B9 (Word insert images/shapes with +// positioning). See word_document_properties_test.cpp's header comment for +// scope/limits shared by every file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/wordcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_CreateImage_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.createImage")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), 0); + scope.insert(QStringLiteral("imageSrc"), QStringLiteral("data:image/png;base64,AAAA")); + scope.insert(QStringLiteral("width"), 914400); + scope.insert(QStringLiteral("height"), 914400); + return spec->validate(scope).isEmpty(); + } + + bool Test_CreateImage_ZeroWidth_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.createImage")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), 0); + scope.insert(QStringLiteral("imageSrc"), QStringLiteral("data:image/png;base64,AAAA")); + scope.insert(QStringLiteral("width"), 0); + scope.insert(QStringLiteral("height"), 914400); + return !spec->validate(scope).isEmpty(); + } + + bool Test_SetWrappingStyle_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setWrappingStyle")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("drawingIndex"), 0); + scope.insert(QStringLiteral("style"), QStringLiteral("square")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetWrappingStyle_UnknownStyle_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setWrappingStyle")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("drawingIndex"), 0); + scope.insert(QStringLiteral("style"), QStringLiteral("bogus")); + return !spec->validate(scope).isEmpty(); + } + + bool Test_SetHorPosition_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setHorPosition")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("drawingIndex"), 0); + scope.insert(QStringLiteral("distanceEmu"), 200000); + scope.insert(QStringLiteral("relativeTo"), QStringLiteral("page")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetHorPosition_UnknownRelativeTo_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setHorPosition")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("drawingIndex"), 0); + scope.insert(QStringLiteral("distanceEmu"), 200000); + scope.insert(QStringLiteral("relativeTo"), QStringLiteral("bogus")); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterWordCommands(); + + const std::vector>> tests = { + {"CreateImage_ValidScope_PassesValidation", Test_CreateImage_ValidScope_PassesValidation}, + {"CreateImage_ZeroWidth_SchemaInvalid", Test_CreateImage_ZeroWidth_SchemaInvalid}, + {"SetWrappingStyle_ValidScope_PassesValidation", Test_SetWrappingStyle_ValidScope_PassesValidation}, + {"SetWrappingStyle_UnknownStyle_SchemaInvalid", Test_SetWrappingStyle_UnknownStyle_SchemaInvalid}, + {"SetHorPosition_ValidScope_PassesValidation", Test_SetHorPosition_ValidScope_PassesValidation}, + {"SetHorPosition_UnknownRelativeTo_SchemaInvalid", Test_SetHorPosition_UnknownRelativeTo_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/word_insert_edit_text_test.cpp b/win-linux/tests/gateway/word_insert_edit_text_test.cpp new file mode 100644 index 000000000..a6f632e51 --- /dev/null +++ b/win-linux/tests/gateway/word_insert_edit_text_test.cpp @@ -0,0 +1,102 @@ +// Test cases from gateway-test-case-designs.md §B3 (Word insert/edit text). See +// word_document_properties_test.cpp's header comment for the scope/limits shared by +// every file in this directory: schema validation only, round trips deferred to §6. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/wordcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_AddText_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.addText")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), 0); + scope.insert(QStringLiteral("text"), QStringLiteral("Hello")); + return spec->validate(scope).isEmpty(); + } + + // B3.4: empty text is allowed. + bool Test_AddText_EmptyText_Accepted() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.addText")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), 0); + scope.insert(QStringLiteral("text"), QString()); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddText_MissingParaIndex_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.addText")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("text"), QStringLiteral("x")); + return !spec->validate(scope).isEmpty(); + } + + bool Test_AddText_NegativeParaIndex_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.addText")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), -1); + scope.insert(QStringLiteral("text"), QStringLiteral("x")); + return !spec->validate(scope).isEmpty(); + } + + bool Test_GetText_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.getText")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), 1); + scope.insert(QStringLiteral("runIndex"), 0); + return spec->validate(scope).isEmpty(); + } + + bool Test_GetText_MissingRunIndex_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.getText")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), 1); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterWordCommands(); + + const std::vector>> tests = { + {"AddText_ValidScope_PassesValidation", Test_AddText_ValidScope_PassesValidation}, + {"AddText_EmptyText_Accepted", Test_AddText_EmptyText_Accepted}, + {"AddText_MissingParaIndex_SchemaInvalid", Test_AddText_MissingParaIndex_SchemaInvalid}, + {"AddText_NegativeParaIndex_SchemaInvalid", Test_AddText_NegativeParaIndex_SchemaInvalid}, + {"GetText_ValidScope_PassesValidation", Test_GetText_ValidScope_PassesValidation}, + {"GetText_MissingRunIndex_SchemaInvalid", Test_GetText_MissingRunIndex_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/word_page_setup_test.cpp b/win-linux/tests/gateway/word_page_setup_test.cpp new file mode 100644 index 000000000..6bccccca7 --- /dev/null +++ b/win-linux/tests/gateway/word_page_setup_test.cpp @@ -0,0 +1,95 @@ +// Test cases from gateway-test-case-designs.md §B10 (Word headers/footers, page +// setup). See word_document_properties_test.cpp's header comment for scope/limits +// shared by every file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/wordcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_SetHeaderText_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setHeaderText")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("type"), QStringLiteral("default")); + scope.insert(QStringLiteral("text"), QStringLiteral("Confidential")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetHeaderText_UnknownType_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setHeaderText")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("type"), QStringLiteral("bogus")); + scope.insert(QStringLiteral("text"), QStringLiteral("x")); + return !spec->validate(scope).isEmpty(); + } + + bool Test_SetPageMargins_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setPageMargins")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("left"), 1440); + scope.insert(QStringLiteral("top"), 1440); + scope.insert(QStringLiteral("right"), 1440); + scope.insert(QStringLiteral("bottom"), 1440); + return spec->validate(scope).isEmpty(); + } + + // B10.3 + bool Test_SetPageSize_ZeroWidth_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setPageSize")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("width"), 0); + scope.insert(QStringLiteral("height"), 0); + return !spec->validate(scope).isEmpty(); + } + + bool Test_SetPageSize_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setPageSize")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("width"), 12240); + scope.insert(QStringLiteral("height"), 15840); + return spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterWordCommands(); + + const std::vector>> tests = { + {"SetHeaderText_ValidScope_PassesValidation", Test_SetHeaderText_ValidScope_PassesValidation}, + {"SetHeaderText_UnknownType_SchemaInvalid", Test_SetHeaderText_UnknownType_SchemaInvalid}, + {"SetPageMargins_ValidScope_PassesValidation", Test_SetPageMargins_ValidScope_PassesValidation}, + {"SetPageSize_ZeroWidth_SchemaInvalid", Test_SetPageSize_ZeroWidth_SchemaInvalid}, + {"SetPageSize_ValidScope_PassesValidation", Test_SetPageSize_ValidScope_PassesValidation}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/word_paragraph_formatting_test.cpp b/win-linux/tests/gateway/word_paragraph_formatting_test.cpp new file mode 100644 index 000000000..6e0fe660e --- /dev/null +++ b/win-linux/tests/gateway/word_paragraph_formatting_test.cpp @@ -0,0 +1,93 @@ +// Test cases from gateway-test-case-designs.md §B5 (Word paragraph formatting). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/wordcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_SetJc_ValidAlignment_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setJc")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), 0); + scope.insert(QStringLiteral("align"), QStringLiteral("center")); + return spec->validate(scope).isEmpty(); + } + + // B5.2 + bool Test_SetJc_InvalidAlignment_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setJc")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), 0); + scope.insert(QStringLiteral("align"), QStringLiteral("diagonal")); + return !spec->validate(scope).isEmpty(); + } + + bool Test_SetSpacingBefore_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setSpacingBefore")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), 0); + scope.insert(QStringLiteral("twips"), 240); + return spec->validate(scope).isEmpty(); + } + + // B5.4: negative twips (hanging indent) is valid for setIndLeft. + bool Test_SetIndLeft_NegativeTwips_StillValid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setIndLeft")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), 0); + scope.insert(QStringLiteral("twips"), -100); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetIndLeft_MissingTwips_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setIndLeft")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("paraIndex"), 0); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterWordCommands(); + + const std::vector>> tests = { + {"SetJc_ValidAlignment_PassesValidation", Test_SetJc_ValidAlignment_PassesValidation}, + {"SetJc_InvalidAlignment_SchemaInvalid", Test_SetJc_InvalidAlignment_SchemaInvalid}, + {"SetSpacingBefore_ValidScope_PassesValidation", Test_SetSpacingBefore_ValidScope_PassesValidation}, + {"SetIndLeft_NegativeTwips_StillValid", Test_SetIndLeft_NegativeTwips_StillValid}, + {"SetIndLeft_MissingTwips_SchemaInvalid", Test_SetIndLeft_MissingTwips_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/word_search_replace_test.cpp b/win-linux/tests/gateway/word_search_replace_test.cpp new file mode 100644 index 000000000..b8840feb2 --- /dev/null +++ b/win-linux/tests/gateway/word_search_replace_test.cpp @@ -0,0 +1,78 @@ +// Test cases from gateway-test-case-designs.md §B6 (Word search & replace). See +// word_document_properties_test.cpp's header comment for scope/limits shared by every +// file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/wordcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_Search_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.search")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("text"), QStringLiteral("foo")); + return spec->validate(scope).isEmpty(); + } + + bool Test_Search_EmptyText_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.search")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("text"), QString()); + return !spec->validate(scope).isEmpty(); + } + + bool Test_SearchAndReplace_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.searchAndReplace")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("find"), QStringLiteral("foo")); + scope.insert(QStringLiteral("replace"), QStringLiteral("bar")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SearchAndReplace_MissingFind_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.searchAndReplace")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("replace"), QStringLiteral("bar")); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterWordCommands(); + + const std::vector>> tests = { + {"Search_ValidScope_PassesValidation", Test_Search_ValidScope_PassesValidation}, + {"Search_EmptyText_SchemaInvalid", Test_Search_EmptyText_SchemaInvalid}, + {"SearchAndReplace_ValidScope_PassesValidation", Test_SearchAndReplace_ValidScope_PassesValidation}, + {"SearchAndReplace_MissingFind_SchemaInvalid", Test_SearchAndReplace_MissingFind_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/word_style_test.cpp b/win-linux/tests/gateway/word_style_test.cpp new file mode 100644 index 000000000..5086e584d --- /dev/null +++ b/win-linux/tests/gateway/word_style_test.cpp @@ -0,0 +1,90 @@ +// Test cases from gateway-test-case-designs.md §B8 (Word style creation and +// application). See word_document_properties_test.cpp's header comment for +// scope/limits shared by every file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/wordcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_CreateStyle_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.createStyle")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("name"), QStringLiteral("MyHeading")); + scope.insert(QStringLiteral("type"), QStringLiteral("paragraph")); + return spec->validate(scope).isEmpty(); + } + + bool Test_CreateStyle_InvalidType_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.createStyle")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("name"), QStringLiteral("MyHeading")); + scope.insert(QStringLiteral("type"), QStringLiteral("bogus")); + return !spec->validate(scope).isEmpty(); + } + + bool Test_GetStyle_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.getStyle")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("name"), QStringLiteral("MyHeading")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetStyleTextPr_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setStyleTextPr")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("styleId"), QStringLiteral("MyHeading")); + scope.insert(QStringLiteral("bold"), true); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetStyleTextPr_MissingBold_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setStyleTextPr")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("styleId"), QStringLiteral("MyHeading")); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterWordCommands(); + + const std::vector>> tests = { + {"CreateStyle_ValidScope_PassesValidation", Test_CreateStyle_ValidScope_PassesValidation}, + {"CreateStyle_InvalidType_SchemaInvalid", Test_CreateStyle_InvalidType_SchemaInvalid}, + {"GetStyle_ValidScope_PassesValidation", Test_GetStyle_ValidScope_PassesValidation}, + {"SetStyleTextPr_ValidScope_PassesValidation", Test_SetStyleTextPr_ValidScope_PassesValidation}, + {"SetStyleTextPr_MissingBold_SchemaInvalid", Test_SetStyleTextPr_MissingBold_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tests/gateway/word_table_test.cpp b/win-linux/tests/gateway/word_table_test.cpp new file mode 100644 index 000000000..c1fc11e16 --- /dev/null +++ b/win-linux/tests/gateway/word_table_test.cpp @@ -0,0 +1,108 @@ +// Test cases from gateway-test-case-designs.md §B7 (Word table creation and editing). +// See word_document_properties_test.cpp's header comment for scope/limits shared by +// every file in this directory. + +#include "../../src/gateway/allowlist.h" +#include "../../src/gateway/commands/wordcommands.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + bool Test_AddRow_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.addRow")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("tableIndex"), 0); + scope.insert(QStringLiteral("rowIndex"), 1); + return spec->validate(scope).isEmpty(); + } + + bool Test_AddColumn_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.addColumn")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("tableIndex"), 0); + scope.insert(QStringLiteral("colIndex"), 1); + return spec->validate(scope).isEmpty(); + } + + bool Test_MergeCells_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.mergeCells")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("tableIndex"), 0); + scope.insert(QStringLiteral("fromRow"), 0); + scope.insert(QStringLiteral("fromCol"), 0); + scope.insert(QStringLiteral("toRow"), 0); + scope.insert(QStringLiteral("toCol"), 1); + return spec->validate(scope).isEmpty(); + } + + bool Test_MergeCells_MissingToCol_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.mergeCells")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("tableIndex"), 0); + scope.insert(QStringLiteral("fromRow"), 0); + scope.insert(QStringLiteral("fromCol"), 0); + scope.insert(QStringLiteral("toRow"), 0); + return !spec->validate(scope).isEmpty(); + } + + bool Test_SetStyle_ValidScope_PassesValidation() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setStyle")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("tableIndex"), 0); + scope.insert(QStringLiteral("styleId"), QStringLiteral("TableGrid")); + return spec->validate(scope).isEmpty(); + } + + bool Test_SetStyle_EmptyStyleId_SchemaInvalid() + { + const Gateway::CommandSpec* spec = Gateway::AllowlistTable::Instance().Find(QStringLiteral("word.setStyle")); + if (!spec) return false; + QJsonObject scope; + scope.insert(QStringLiteral("tableIndex"), 0); + scope.insert(QStringLiteral("styleId"), QString()); + return !spec->validate(scope).isEmpty(); + } +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + Gateway::Commands::RegisterWordCommands(); + + const std::vector>> tests = { + {"AddRow_ValidScope_PassesValidation", Test_AddRow_ValidScope_PassesValidation}, + {"AddColumn_ValidScope_PassesValidation", Test_AddColumn_ValidScope_PassesValidation}, + {"MergeCells_ValidScope_PassesValidation", Test_MergeCells_ValidScope_PassesValidation}, + {"MergeCells_MissingToCol_SchemaInvalid", Test_MergeCells_MissingToCol_SchemaInvalid}, + {"SetStyle_ValidScope_PassesValidation", Test_SetStyle_ValidScope_PassesValidation}, + {"SetStyle_EmptyStyleId_SchemaInvalid", Test_SetStyle_EmptyStyleId_SchemaInvalid}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tools/eo-ctl/build/cmake/CMakeLists.txt b/win-linux/tools/eo-ctl/build/cmake/CMakeLists.txt new file mode 100644 index 000000000..bd1a92949 --- /dev/null +++ b/win-linux/tools/eo-ctl/build/cmake/CMakeLists.txt @@ -0,0 +1,25 @@ +# Standalone CLI target, shaped after core/X2tConverter/build/cmake/CMakeLists.txt +# (see cdp-gateway-cli-plan.md §3/§8 for why this precedent was chosen over folding +# eo-ctl's sources into DesktopEditors' own COMMON_SOURCES the way update-daemon is). + +project(eo-ctl) + +set(EO_CTL_ROOT_DIR "${CMAKE_CURRENT_LIST_DIR}/../..") + +find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Network) + +add_executable(eo-ctl + "${EO_CTL_ROOT_DIR}/src/main.cpp" + "${EO_CTL_ROOT_DIR}/src/connectlogic.cpp" +) + +set_target_properties(eo-ctl PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC ON +) + +target_link_libraries(eo-ctl PRIVATE + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Network +) diff --git a/win-linux/tools/eo-ctl/src/connectlogic.cpp b/win-linux/tools/eo-ctl/src/connectlogic.cpp new file mode 100644 index 000000000..4cb7352d1 --- /dev/null +++ b/win-linux/tools/eo-ctl/src/connectlogic.cpp @@ -0,0 +1,39 @@ +#include "connectlogic.h" + +namespace EoCtl +{ + int ConnectAndResolveViewId( + bool socketAlreadyExists, + const std::function& ensureSocketRunning, + const std::function& resolveViewId, + const std::function& launchForFileOpen, + const std::function& sleepMs, + int maxWaitMs, + int pollIntervalMs) + { + if (!socketAlreadyExists) + { + if (!ensureSocketRunning()) + return -1; + // Cold start: the instance we just launched opened `file` itself as its + // initial document -- resolve below rather than launching again. + } + + int viewId = resolveViewId(); + if (viewId != -1) + return viewId; + + if (socketAlreadyExists) + launchForFileOpen(); + + for (int waited = 0; waited < maxWaitMs; waited += pollIntervalMs) + { + sleepMs(pollIntervalMs); + viewId = resolveViewId(); + if (viewId != -1) + return viewId; + } + + return -1; + } +} diff --git a/win-linux/tools/eo-ctl/src/connectlogic.h b/win-linux/tools/eo-ctl/src/connectlogic.h new file mode 100644 index 000000000..bdba2aeda --- /dev/null +++ b/win-linux/tools/eo-ctl/src/connectlogic.h @@ -0,0 +1,49 @@ +#ifndef EOCTL_CONNECTLOGIC_H +#define EOCTL_CONNECTLOGIC_H + +#include + +namespace EoCtl +{ + // Pure polling logic behind `eo-ctl connect `, extracted out of main.cpp so + // it's unit-testable against fakes rather than a live gateway/DesktopEditors + // process -- same "no business logic in main, dependency-inject for testability" + // shape as GatewayCommandRunner::Execute. + // + // The gateway's `gateway.connect` meta command (gatewayserver.cpp) is a pure + // resolver: it never opens anything itself, so ANY view resolution has to be + // driven from here. The one thing that actually opens a file is launching + // `DesktopEditors ` as a subprocess -- SingleApplication (main.cpp, + // cascapplicationmanagerwrapper.cpp) makes that work identically whether or not an + // instance is already running: cold start opens `file` directly as the initial + // document; a second launch forwards the path via sendMessage and the existing + // instance opens it as a new tab via handleInputCmd. So this function never needs + // to distinguish those cases beyond "was a launch already implied by getting the + // socket up" -- it just launches once if resolution comes back empty, then polls. + // + // `socketAlreadyExists`: whether the gateway socket existed before this call + // (skips the redundant "ensure socket running" launch below if so). + // `ensureSocketRunning`: launches DesktopEditors and blocks (bounded) until the + // socket exists; returns false on failure/timeout. Only called when + // `socketAlreadyExists` is false. + // `resolveViewId`: calls gateway.connect{path: file} and returns the resulting + // targetViewId, or -1 if the file isn't open yet (per gatewayserver.cpp's + // contract -- not an error). + // `launchForFileOpen`: launches `DesktopEditors ` again to trigger + // SingleApplication's forward-and-open-a-new-tab path. Only called when + // `socketAlreadyExists` is true and the first resolveViewId() came back + // unresolved. + // `sleepMs`: injected so tests don't actually sleep. + // + // Returns the resolved targetViewId, or -1 if it never resolved within maxWaitMs. + int ConnectAndResolveViewId( + bool socketAlreadyExists, + const std::function& ensureSocketRunning, + const std::function& resolveViewId, + const std::function& launchForFileOpen, + const std::function& sleepMs, + int maxWaitMs = 30000, + int pollIntervalMs = 200); +} + +#endif // EOCTL_CONNECTLOGIC_H diff --git a/win-linux/tools/eo-ctl/src/main.cpp b/win-linux/tools/eo-ctl/src/main.cpp new file mode 100644 index 000000000..2d1e52183 --- /dev/null +++ b/win-linux/tools/eo-ctl/src/main.cpp @@ -0,0 +1,227 @@ +// eo-ctl — thin client + process lifecycle manager for the DesktopEditors gateway. +// Per cdp-gateway-cli-plan.md §7: no business logic lives here. Every subcommand just +// frames a JSON request, sends it over the gateway's Unix-domain socket, and prints +// the JSON response — the same GatewayCommandRunner::Execute() call the gateway itself +// makes is what actually runs, per gateway-test-case-designs.md's "Scope of this +// document". If this file starts growing per-command validation logic, that logic +// belongs in the allowlist table instead. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "connectlogic.h" + +namespace +{ + QString SocketPath() + { + QString runtimeDir = QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation); + if (runtimeDir.isEmpty()) + runtimeDir = QDir::tempPath(); + return QStringLiteral("%1/eo-gateway-%2.sock").arg(runtimeDir).arg(getuid()); + } + + QString TokenPath() + { + QString runtimeDir = QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation); + if (runtimeDir.isEmpty()) + runtimeDir = QDir::tempPath(); + return QStringLiteral("%1/eo-gateway-%2.token").arg(runtimeDir).arg(getuid()); + } + + // Launches DesktopEditors on `file` and waits (bounded) for its gateway socket to + // appear, per §7: "if none is running for the target document, it launches + // DesktopEditors itself (waiting for the gateway socket to come up)". + bool EnsureEditorRunning(const QString& file, QTextStream& err) + { + if (QFile::exists(SocketPath())) + return true; + + if (!QProcess::startDetached(QStringLiteral("DesktopEditors"), {file})) + { + err << "eo-ctl: failed to launch DesktopEditors\n"; + return false; + } + + const int timeoutMs = 30000; + const int pollIntervalMs = 200; + for (int waited = 0; waited < timeoutMs; waited += pollIntervalMs) + { + if (QFile::exists(SocketPath())) + return true; + QThread::msleep(pollIntervalMs); + } + + err << "eo-ctl: timed out waiting for the gateway socket to appear\n"; + return false; + } + + // Shared by `call` and `allowlist` — connect, send one framed request, read one + // framed response, disconnect. Matches GatewayServer's one-shot-per-connection + // protocol (gatewayserver.cpp). + bool SendRequest(const QJsonObject& request, QJsonObject& outResponse, QTextStream& err) + { + QFile tokenFile(TokenPath()); + if (!tokenFile.open(QIODevice::ReadOnly)) + { + err << "eo-ctl: could not read auth token at " << TokenPath() << "\n"; + return false; + } + const QString token = QString::fromUtf8(tokenFile.readAll()); + + QLocalSocket socket; + socket.connectToServer(SocketPath()); + if (!socket.waitForConnected(5000)) + { + err << "eo-ctl: could not connect to gateway socket: " << socket.errorString() << "\n"; + return false; + } + + QJsonObject framed = request; + framed.insert(QStringLiteral("auth"), token); + socket.write(QJsonDocument(framed).toJson(QJsonDocument::Compact) + '\n'); + socket.flush(); + + if (!socket.waitForReadyRead(10000) || !socket.canReadLine()) + { + err << "eo-ctl: no response from gateway (timed out)\n"; + return false; + } + + outResponse = QJsonDocument::fromJson(socket.readLine().trimmed()).object(); + return true; + } +} + +int main(int argc, char* argv[]) +{ + QCoreApplication app(argc, argv); + QCoreApplication::setApplicationName(QStringLiteral("eo-ctl")); + + QTextStream out(stdout); + QTextStream err(stderr); + + QCommandLineParser parser; + parser.setApplicationDescription(QStringLiteral("Thin client for the DesktopEditors gateway")); + parser.addHelpOption(); + parser.addPositionalArgument(QStringLiteral("subcommand"), QStringLiteral("connect | call | allowlist")); + parser.parse(QCoreApplication::arguments()); + + const QStringList args = parser.positionalArguments(); + if (args.isEmpty()) + { + err << "usage: eo-ctl | call --scope '' | allowlist>\n"; + return 1; + } + + const QString subcommand = args.first(); + + if (subcommand == QStringLiteral("connect")) + { + if (args.size() < 2) + { + err << "usage: eo-ctl connect \n"; + return 1; + } + + const QString file = args.at(1); + const bool socketAlreadyExists = QFile::exists(SocketPath()); + + // gateway.connect is a pure resolver (never opens anything itself, see + // gatewayserver.cpp) -- launching DesktopEditors is what actually + // opens the document, via SingleApplication's cold-start-or-forward + // behavior; see connectlogic.h for the full rationale. + auto resolveViewId = [&file, &err]() -> int { + QJsonObject request; + request.insert(QStringLiteral("id"), QStringLiteral("eo-ctl-connect")); + request.insert(QStringLiteral("command"), QStringLiteral("gateway.connect")); + QJsonObject scope; + scope.insert(QStringLiteral("path"), file); + request.insert(QStringLiteral("scope"), scope); + + QJsonObject response; + if (!SendRequest(request, response, err) || !response.value(QStringLiteral("ok")).toBool()) + return -1; + return response.value(QStringLiteral("result")).toObject() + .value(QStringLiteral("targetViewId")).toInt(-1); + }; + + const int viewId = EoCtl::ConnectAndResolveViewId( + socketAlreadyExists, + [&file, &err]() { return EnsureEditorRunning(file, err); }, + resolveViewId, + [&file]() { QProcess::startDetached(QStringLiteral("DesktopEditors"), {file}); }, + [](int ms) { QThread::msleep(static_cast(ms)); }); + + if (viewId == -1) + { + err << "eo-ctl: timed out resolving a view for " << file << "\n"; + return 1; + } + + QJsonObject result; + result.insert(QStringLiteral("targetViewId"), viewId); + out << QString::fromUtf8(QJsonDocument(result).toJson(QJsonDocument::Compact)) << "\n"; + return 0; + } + + if (subcommand == QStringLiteral("call")) + { + if (args.size() < 2) + { + err << "usage: eo-ctl call --scope ''\n"; + return 1; + } + + QCommandLineOption scopeOption(QStringLiteral("scope"), QStringLiteral("JSON scope object"), QStringLiteral("json"), QStringLiteral("{}")); + QCommandLineOption targetOption(QStringLiteral("target"), QStringLiteral("target view id"), QStringLiteral("id"), QStringLiteral("-1")); + QCommandLineParser callParser; + callParser.addOption(scopeOption); + callParser.addOption(targetOption); + callParser.parse(QCoreApplication::arguments()); + + const QJsonObject scope = QJsonDocument::fromJson(callParser.value(scopeOption).toUtf8()).object(); + + QJsonObject request; + request.insert(QStringLiteral("id"), QStringLiteral("eo-ctl-1")); + request.insert(QStringLiteral("command"), args.at(1)); + request.insert(QStringLiteral("scope"), scope); + request.insert(QStringLiteral("targetViewId"), callParser.value(targetOption).toInt()); + + QJsonObject response; + if (!SendRequest(request, response, err)) + return 1; + + out << QString::fromUtf8(QJsonDocument(response).toJson(QJsonDocument::Compact)) << "\n"; + return response.value(QStringLiteral("ok")).toBool() ? 0 : 1; + } + + if (subcommand == QStringLiteral("allowlist")) + { + QJsonObject request; + request.insert(QStringLiteral("id"), QStringLiteral("eo-ctl-allowlist")); + request.insert(QStringLiteral("command"), QStringLiteral("gateway.listCommands")); + request.insert(QStringLiteral("scope"), QJsonObject()); + + QJsonObject response; + if (!SendRequest(request, response, err)) + return 1; + + out << QString::fromUtf8(QJsonDocument(response).toJson(QJsonDocument::Compact)) << "\n"; + return response.value(QStringLiteral("ok")).toBool() ? 0 : 1; + } + + err << "eo-ctl: unknown subcommand '" << subcommand << "'\n"; + return 1; +} diff --git a/win-linux/tools/eo-ctl/tests/CMakeLists.txt b/win-linux/tools/eo-ctl/tests/CMakeLists.txt new file mode 100644 index 000000000..66e23d8b6 --- /dev/null +++ b/win-linux/tools/eo-ctl/tests/CMakeLists.txt @@ -0,0 +1,17 @@ +# connectlogic is pure C++17 (no Qt dependency -- see connectlogic.h), so its test +# doesn't need find_package(Qt...) or AUTOMOC, unlike everything under tests/gateway. + +add_executable(eo_ctl_connectlogic_test + connectlogic_test.cpp + ${CMAKE_CURRENT_LIST_DIR}/../src/connectlogic.cpp +) + +set_target_properties(eo_ctl_connectlogic_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON +) + +add_test( + NAME eo_ctl_connectlogic + COMMAND eo_ctl_connectlogic_test +) diff --git a/win-linux/tools/eo-ctl/tests/connectlogic_test.cpp b/win-linux/tools/eo-ctl/tests/connectlogic_test.cpp new file mode 100644 index 000000000..af483ae1b --- /dev/null +++ b/win-linux/tools/eo-ctl/tests/connectlogic_test.cpp @@ -0,0 +1,135 @@ +// Unit tests for EoCtl::ConnectAndResolveViewId (connectlogic.h/.cpp) -- pure logic, +// no live gateway/DesktopEditors process needed; every dependency is injected as a +// fake. See connectlogic.h's header comment for the rationale behind each branch. + +#include "../src/connectlogic.h" + +#include +#include +#include +#include + +namespace +{ + bool Test_AlreadyOpen_SocketExists_ResolvesImmediately_NoLaunch() + { + int launchCalls = 0; + int resolveCalls = 0; + + const int viewId = EoCtl::ConnectAndResolveViewId( + /*socketAlreadyExists=*/true, + /*ensureSocketRunning=*/[]() -> bool { return true; }, // must not be called + /*resolveViewId=*/[&resolveCalls]() -> int { ++resolveCalls; return 7; }, + /*launchForFileOpen=*/[&launchCalls]() { ++launchCalls; }, + /*sleepMs=*/[](int) {}); + + return viewId == 7 && resolveCalls == 1 && launchCalls == 0; + } + + bool Test_ColdStart_NoSocket_LaunchesAndResolves() + { + bool ensureCalled = false; + int launchForFileOpenCalls = 0; + + const int viewId = EoCtl::ConnectAndResolveViewId( + /*socketAlreadyExists=*/false, + /*ensureSocketRunning=*/[&ensureCalled]() -> bool { ensureCalled = true; return true; }, + /*resolveViewId=*/[]() -> int { return 3; }, + /*launchForFileOpen=*/[&launchForFileOpenCalls]() { ++launchForFileOpenCalls; }, + /*sleepMs=*/[](int) {}); + + // Cold start: the just-launched instance already opened the file itself -- + // launchForFileOpen (the "forward to running instance" path) must NOT also fire. + return viewId == 3 && ensureCalled && launchForFileOpenCalls == 0; + } + + bool Test_ColdStart_EnsureSocketRunningFails_ReturnsMinusOne() + { + const int viewId = EoCtl::ConnectAndResolveViewId( + /*socketAlreadyExists=*/false, + /*ensureSocketRunning=*/[]() -> bool { return false; }, + /*resolveViewId=*/[]() -> int { return 5; }, // must not be reached + /*launchForFileOpen=*/[]() {}, + /*sleepMs=*/[](int) {}); + + return viewId == -1; + } + + bool Test_SocketExists_FileNotOpenYet_LaunchesForFileOpen_ThenPolls() + { + int launchCalls = 0; + int resolveCalls = 0; + + const int viewId = EoCtl::ConnectAndResolveViewId( + /*socketAlreadyExists=*/true, + /*ensureSocketRunning=*/[]() -> bool { return true; }, + /*resolveViewId=*/[&resolveCalls]() -> int { + ++resolveCalls; + return resolveCalls < 3 ? -1 : 9; // resolves on the 3rd attempt + }, + /*launchForFileOpen=*/[&launchCalls]() { ++launchCalls; }, + /*sleepMs=*/[](int) {}, + /*maxWaitMs=*/10000, + /*pollIntervalMs=*/100); + + return viewId == 9 && launchCalls == 1 && resolveCalls == 3; + } + + bool Test_NeverResolves_TimesOut_ReturnsMinusOne() + { + int sleepCalls = 0; + + const int viewId = EoCtl::ConnectAndResolveViewId( + /*socketAlreadyExists=*/true, + /*ensureSocketRunning=*/[]() -> bool { return true; }, + /*resolveViewId=*/[]() -> int { return -1; }, + /*launchForFileOpen=*/[]() {}, + /*sleepMs=*/[&sleepCalls](int) { ++sleepCalls; }, + /*maxWaitMs=*/1000, + /*pollIntervalMs=*/200); + + // 1000/200 = 5 poll iterations, each preceded by one sleep. + return viewId == -1 && sleepCalls == 5; + } + + bool Test_SleepMs_ReceivesThePollInterval() + { + std::vector sleptFor; + + EoCtl::ConnectAndResolveViewId( + /*socketAlreadyExists=*/true, + /*ensureSocketRunning=*/[]() -> bool { return true; }, + /*resolveViewId=*/[]() -> int { return -1; }, + /*launchForFileOpen=*/[]() {}, + /*sleepMs=*/[&sleptFor](int ms) { sleptFor.push_back(ms); }, + /*maxWaitMs=*/600, + /*pollIntervalMs=*/150); + + for (int ms : sleptFor) + if (ms != 150) return false; + return sleptFor.size() == 4; // 600/150 + } +} + +int main() +{ + const std::vector>> tests = { + {"AlreadyOpen_SocketExists_ResolvesImmediately_NoLaunch", Test_AlreadyOpen_SocketExists_ResolvesImmediately_NoLaunch}, + {"ColdStart_NoSocket_LaunchesAndResolves", Test_ColdStart_NoSocket_LaunchesAndResolves}, + {"ColdStart_EnsureSocketRunningFails_ReturnsMinusOne", Test_ColdStart_EnsureSocketRunningFails_ReturnsMinusOne}, + {"SocketExists_FileNotOpenYet_LaunchesForFileOpen_ThenPolls", Test_SocketExists_FileNotOpenYet_LaunchesForFileOpen_ThenPolls}, + {"NeverResolves_TimesOut_ReturnsMinusOne", Test_NeverResolves_TimesOut_ReturnsMinusOne}, + {"SleepMs_ReceivesThePollInterval", Test_SleepMs_ReceivesThePollInterval}, + }; + + int failures = 0; + for (const auto& test : tests) + { + const bool passed = test.second(); + std::printf("[%s] %s\n", passed ? "PASS" : "FAIL", test.first.c_str()); + if (!passed) ++failures; + } + + std::printf("%zu tests, %d failed\n", tests.size(), failures); + return failures == 0 ? 0 : 1; +} diff --git a/win-linux/tools/eo-mcp/.gitignore b/win-linux/tools/eo-mcp/.gitignore new file mode 100644 index 000000000..c2658d7d1 --- /dev/null +++ b/win-linux/tools/eo-mcp/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/win-linux/tools/eo-mcp/README.md b/win-linux/tools/eo-mcp/README.md new file mode 100644 index 000000000..4bd853922 --- /dev/null +++ b/win-linux/tools/eo-mcp/README.md @@ -0,0 +1,53 @@ +# eo-mcp + +Thin MCP server wrapping the DesktopEditors gateway (see +[`../../src/gateway/`](../../src/gateway/), [`gateway-api-reference.md`](../../../../gateway-api-reference.md), +and the design doc at `~/repos/eo-mcp-service-plan.md`). Not part of the DesktopEditors +build — a standalone Node package, spawned by an MCP host over stdio. + +## Setup + +```bash +cd tools/eo-mcp +npm install +``` + +## Running + +```bash +npm start +# or, once installed globally / linked: +eo-mcp +``` + +Configure your MCP host to spawn `node /src/index.js` (or the `eo-mcp` +bin) over stdio. No configuration/env vars needed — it reads the gateway's auth token +from `$XDG_RUNTIME_DIR/eo-gateway-.token`, same as `eo-ctl`. + +## Tools + +- **`gateway_connect(file)`** → `{targetViewId}`. Resolves `file` to a stable id, + opening it (launching DesktopEditors, or opening a new tab in an already-running + instance) if not already open. Idempotent — call again for a file already opened + earlier in the conversation to get the same id back. Call once per file before + `gateway_call`. +- **`gateway_call(command, scope, targetViewId)`** → command result, or an MCP tool + error on failure. See `gateway-api-reference.md` for every command's `scope` shape. +- **`gateway_list_commands()`** → array of every registered command name. + +## Testing + +```bash +npm test +``` + +Runs `test/gatewayClient.test.js` via Node's built-in test runner (`node --test`) — +no live gateway or DesktopEditors process required: + +- `sendRequest`/`callCommand`/`listCommands` are tested against a real + in-process `net.Server` speaking the gateway's exact one-shot wire protocol (genuine + integration coverage of the wire format, not a mock of it). +- `connectAndResolveViewId` is pure logic with every dependency injected as a fake, + covering the same branches as `tools/eo-ctl/tests/connectlogic_test.cpp` (cold start, + already-open, forward-and-poll, timeout) — see `gatewayClient.js`'s header comment + for why the two are the same algorithm in two languages. diff --git a/win-linux/tools/eo-mcp/package.json b/win-linux/tools/eo-mcp/package.json new file mode 100644 index 000000000..254a399f2 --- /dev/null +++ b/win-linux/tools/eo-mcp/package.json @@ -0,0 +1,21 @@ +{ + "name": "eo-mcp", + "version": "0.1.0", + "description": "Thin MCP wrapper around the DesktopEditors gateway (see ~/repos/eo-mcp-service-plan.md).", + "type": "module", + "private": true, + "bin": { + "eo-mcp": "src/index.js" + }, + "scripts": { + "start": "node src/index.js", + "test": "node --test" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "zod": "^3.23.0" + } +} diff --git a/win-linux/tools/eo-mcp/src/gatewayClient.js b/win-linux/tools/eo-mcp/src/gatewayClient.js new file mode 100644 index 000000000..310f2395a --- /dev/null +++ b/win-linux/tools/eo-mcp/src/gatewayClient.js @@ -0,0 +1,193 @@ +// Thin client for the DesktopEditors gateway (see cdp-gateway-cli-plan.md and +// gateway-api-reference.md). Talks directly to the Unix socket -- does not shell out +// to eo-ctl -- using the exact one-shot protocol GatewayServer implements +// (win-linux/src/gateway/gatewayserver.cpp): connect, write one newline-terminated +// JSON request, read one newline-terminated JSON response, disconnect. + +import net from 'node:net'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; + +export function socketPath(uid = process.getuid()) { + const runtimeDir = process.env.XDG_RUNTIME_DIR || os.tmpdir(); + return path.join(runtimeDir, `eo-gateway-${uid}.sock`); +} + +export function tokenPath(uid = process.getuid()) { + const runtimeDir = process.env.XDG_RUNTIME_DIR || os.tmpdir(); + return path.join(runtimeDir, `eo-gateway-${uid}.token`); +} + +export function socketExists(uid = process.getuid()) { + return fs.existsSync(socketPath(uid)); +} + +function readToken(uid = process.getuid()) { + return fs.readFileSync(tokenPath(uid), 'utf8').trim(); +} + +// Low-level one-shot request/response. `socketPathOverride`/`tokenOverride` exist +// purely so tests can point this at a fake in-process server instead of a real +// gateway -- see test/gatewayClient.test.js. +export function sendRequest(command, scope, targetViewId, options = {}) { + const { + uid, + timeoutMs = 10000, + socketPathOverride, + tokenOverride, + } = options; + + return new Promise((resolve, reject) => { + let target; + let token; + try { + target = socketPathOverride ?? socketPath(uid); + token = tokenOverride ?? readToken(uid); + } catch (err) { + reject(err); + return; + } + + const socket = net.createConnection(target); + let buffer = ''; + let settled = false; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + socket.destroy(); + reject(new Error('gateway: no response (timed out)')); + }, timeoutMs); + + socket.on('connect', () => { + const request = { + id: 'eo-mcp-1', + command, + scope: scope ?? {}, + targetViewId: targetViewId ?? -1, + auth: token, + }; + socket.write(JSON.stringify(request) + '\n'); + }); + + socket.on('data', (chunk) => { + if (settled) return; + buffer += chunk.toString('utf8'); + const newlineIndex = buffer.indexOf('\n'); + if (newlineIndex === -1) return; + + settled = true; + clearTimeout(timer); + const line = buffer.slice(0, newlineIndex); + socket.end(); + try { + resolve(JSON.parse(line)); + } catch (err) { + reject(new Error(`gateway: malformed response: ${err.message}`)); + } + }); + + socket.on('error', (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(err); + }); + }); +} + +// Throws on ok:false, mapping the gateway's {code, message} error onto a JS Error +// (with .code set) rather than returning it as a value -- matches the tool-error +// convention MCP tool handlers are expected to use. +export async function callCommand(command, scope, targetViewId, options = {}) { + const response = await sendRequest(command, scope, targetViewId, options); + if (!response.ok) { + const error = new Error(response.error?.message ?? 'gateway command failed'); + error.code = response.error?.code ?? 'UNKNOWN'; + throw error; + } + return response.result; +} + +export function listCommands(options = {}) { + return callCommand('gateway.listCommands', {}, -1, options); +} + +// Pure polling algorithm behind connectFile() below -- every dependency is injected +// so this is unit-testable without a real socket/process. Deliberately the same +// shape as EoCtl::ConnectAndResolveViewId (win-linux/tools/eo-ctl/src/connectlogic.h) +// for the same reason documented there: gateway.connect never opens anything itself +// (see gatewayserver.cpp), so launching DesktopEditors is what actually opens +// the document -- SingleApplication makes that work identically whether it's a cold +// start or a forward to an already-running instance. +export async function connectAndResolveViewId(deps) { + const { + socketAlreadyExists, + ensureSocketRunning, + resolveViewId, + launchForFileOpen, + sleepMs, + maxWaitMs = 30000, + pollIntervalMs = 200, + } = deps; + + if (!socketAlreadyExists) { + const ok = await ensureSocketRunning(); + if (!ok) return -1; + // Cold start: the instance we just launched opened the file itself as its + // initial document -- resolve below rather than launching again. + } + + let viewId = await resolveViewId(); + if (viewId !== -1) return viewId; + + if (socketAlreadyExists) { + await launchForFileOpen(); + } + + for (let waited = 0; waited < maxWaitMs; waited += pollIntervalMs) { + await sleepMs(pollIntervalMs); + viewId = await resolveViewId(); + if (viewId !== -1) return viewId; + } + + return -1; +} + +// Real-world wiring for connectAndResolveViewId: spawns DesktopEditors and polls the +// real socket. This function itself is intentionally thin (just wiring real IO) -- +// the interesting branch logic it delegates to is what's unit-tested. +export async function connectFile(file, options = {}) { + const { uid } = options; + const absoluteFile = path.resolve(file); + + const spawnEditor = () => { + const child = spawn('DesktopEditors', [absoluteFile], { detached: true, stdio: 'ignore' }); + child.unref(); + }; + + const resolveViewId = async () => { + const result = await callCommand('gateway.connect', { path: absoluteFile }, -1, options); + return result.targetViewId; + }; + + const ensureSocketRunning = async () => { + spawnEditor(); + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + if (socketExists(uid)) return true; + await new Promise((resolve) => setTimeout(resolve, 200)); + } + return false; + }; + + return connectAndResolveViewId({ + socketAlreadyExists: socketExists(uid), + ensureSocketRunning, + resolveViewId, + launchForFileOpen: spawnEditor, + sleepMs: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + }); +} diff --git a/win-linux/tools/eo-mcp/src/index.js b/win-linux/tools/eo-mcp/src/index.js new file mode 100644 index 000000000..8484d8a44 --- /dev/null +++ b/win-linux/tools/eo-mcp/src/index.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// eo-mcp -- thin MCP wrapper around the DesktopEditors gateway. Three tools, mirroring +// eo-ctl exactly (see ~/repos/eo-mcp-service-plan.md §2/§4 for why this is the +// "thin/generic" shape rather than one MCP tool per gateway command): gateway_connect, +// gateway_call, gateway_list_commands. No business logic lives here -- see +// gatewayClient.js for the actual protocol/polling logic, which is what's unit-tested. + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; +import { callCommand, listCommands, connectFile } from './gatewayClient.js'; + +const server = new McpServer({ + name: 'eo-mcp', + version: '0.1.0', +}); + +server.tool( + 'gateway_connect', + 'Resolve a file path to a stable targetViewId for the DesktopEditors gateway, ' + + 'opening the file (launching DesktopEditors, or opening a new tab in an already-' + + 'running instance) if it is not already open. Idempotent -- safe to call again ' + + 'for a file already opened earlier in the conversation; returns the same id. ' + + 'Call this once per file before gateway_call.', + { + file: z.string().describe('Path to the document to open/resolve.'), + }, + async ({ file }) => { + const targetViewId = await connectFile(file); + if (targetViewId === -1) { + return { + isError: true, + content: [{ type: 'text', text: `timed out resolving a view for ${file}` }], + }; + } + return { + content: [{ type: 'text', text: JSON.stringify({ targetViewId }) }], + }; + }, +); + +server.tool( + 'gateway_call', + 'Run one allowlisted DesktopEditors gateway command against an already-open ' + + 'document (obtained via gateway_connect). See gateway-api-reference.md for every ' + + 'command\'s scope fields, return shape, and examples.', + { + command: z.string().describe('Gateway command name, e.g. "word.setTitle".'), + scope: z.record(z.any()).default({}).describe('Command-specific parameters.'), + targetViewId: z.number().int().describe('The id returned by gateway_connect.'), + }, + async ({ command, scope, targetViewId }) => { + try { + const result = await callCommand(command, scope, targetViewId); + return { + content: [{ type: 'text', text: JSON.stringify(result ?? null) }], + }; + } catch (err) { + return { + isError: true, + content: [{ type: 'text', text: `${err.code ?? 'ERROR'}: ${err.message}` }], + }; + } + }, +); + +server.tool( + 'gateway_list_commands', + 'List every currently-registered gateway command name.', + {}, + async () => { + const names = await listCommands(); + return { + content: [{ type: 'text', text: JSON.stringify(names) }], + }; + }, +); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/win-linux/tools/eo-mcp/test/gatewayClient.test.js b/win-linux/tools/eo-mcp/test/gatewayClient.test.js new file mode 100644 index 000000000..f251304c4 --- /dev/null +++ b/win-linux/tools/eo-mcp/test/gatewayClient.test.js @@ -0,0 +1,235 @@ +// Automated tests for gatewayClient.js. Two kinds: +// - sendRequest/callCommand/listCommands: a real net.Server in-process, speaking the +// exact one-shot protocol GatewayServer implements, so these are genuine +// integration tests of the wire format -- not mocks of it. +// - connectAndResolveViewId: pure logic, every dependency injected as a fake, +// mirroring win-linux/tools/eo-ctl/tests/connectlogic_test.cpp's cases exactly +// (same algorithm, ported to JS -- see gatewayClient.js's header comment on why). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import net from 'node:net'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { sendRequest, callCommand, listCommands, connectAndResolveViewId } from '../src/gatewayClient.js'; + +// --- fake gateway server helper ------------------------------------------------- + +async function withFakeGateway(handler, testFn) { + const socketPath = path.join(os.tmpdir(), `eo-mcp-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`); + const server = net.createServer((socket) => { + let buffer = ''; + socket.on('data', (chunk) => { + buffer += chunk.toString('utf8'); + const newlineIndex = buffer.indexOf('\n'); + if (newlineIndex === -1) return; + const request = JSON.parse(buffer.slice(0, newlineIndex)); + const response = handler(request); + socket.write(JSON.stringify(response) + '\n'); + }); + }); + + await new Promise((resolve) => server.listen(socketPath, resolve)); + try { + await testFn(socketPath); + } finally { + server.close(); + fs.rmSync(socketPath, { force: true }); + } +} + +// --- sendRequest / callCommand / listCommands ----------------------------------- + +test('sendRequest: frames the request and returns the parsed response', async () => { + let receivedRequest; + await withFakeGateway( + (request) => { + receivedRequest = request; + return { id: request.id, ok: true, result: 42 }; + }, + async (socketPath) => { + const response = await sendRequest('word.getTitle', { a: 1 }, 3, { + socketPathOverride: socketPath, + tokenOverride: 'test-token', + }); + assert.equal(response.ok, true); + assert.equal(response.result, 42); + assert.equal(receivedRequest.command, 'word.getTitle'); + assert.deepEqual(receivedRequest.scope, { a: 1 }); + assert.equal(receivedRequest.targetViewId, 3); + assert.equal(receivedRequest.auth, 'test-token'); + }, + ); +}); + +test('callCommand: resolves with result on ok:true', async () => { + await withFakeGateway( + () => ({ id: 'x', ok: true, result: { title: 'Q3 Report' } }), + async (socketPath) => { + const result = await callCommand('word.getTitle', {}, 1, { + socketPathOverride: socketPath, + tokenOverride: 't', + }); + assert.deepEqual(result, { title: 'Q3 Report' }); + }, + ); +}); + +test('callCommand: throws with .code set on ok:false', async () => { + await withFakeGateway( + () => ({ id: 'x', ok: false, error: { code: 'SCHEMA_INVALID', message: 'bad scope' } }), + async (socketPath) => { + await assert.rejects( + () => callCommand('word.setTitle', {}, 1, { socketPathOverride: socketPath, tokenOverride: 't' }), + (err) => { + assert.equal(err.code, 'SCHEMA_INVALID'); + assert.equal(err.message, 'bad scope'); + return true; + }, + ); + }, + ); +}); + +test('listCommands: calls gateway.listCommands with an empty scope', async () => { + let receivedRequest; + await withFakeGateway( + (request) => { + receivedRequest = request; + return { id: request.id, ok: true, result: ['word.getTitle', 'pdf.getAllFields'] }; + }, + async (socketPath) => { + const names = await listCommands({ socketPathOverride: socketPath, tokenOverride: 't' }); + assert.deepEqual(names, ['word.getTitle', 'pdf.getAllFields']); + assert.equal(receivedRequest.command, 'gateway.listCommands'); + assert.deepEqual(receivedRequest.scope, {}); + }, + ); +}); + +test('sendRequest: rejects on timeout when the server never responds', async () => { + await withFakeGateway( + () => null, // handler return value is ignored -- server below never writes back + async () => { + const socketPath = path.join(os.tmpdir(), `eo-mcp-test-noresponse-${Date.now()}.sock`); + const server = net.createServer(() => {}); // accepts, never writes anything back + await new Promise((resolve) => server.listen(socketPath, resolve)); + try { + await assert.rejects( + () => sendRequest('word.getTitle', {}, 1, { + socketPathOverride: socketPath, + tokenOverride: 't', + timeoutMs: 50, + }), + /timed out/, + ); + } finally { + server.close(); + fs.rmSync(socketPath, { force: true }); + } + }, + ); +}); + +// --- connectAndResolveViewId (pure logic, ported 1:1 from connectlogic_test.cpp) -- + +test('connectAndResolveViewId: already open, socket exists -> resolves immediately, no launch', async () => { + let launchCalls = 0; + let resolveCalls = 0; + + const viewId = await connectAndResolveViewId({ + socketAlreadyExists: true, + ensureSocketRunning: async () => true, // must not be called + resolveViewId: async () => { resolveCalls++; return 7; }, + launchForFileOpen: () => { launchCalls++; }, + sleepMs: async () => {}, + }); + + assert.equal(viewId, 7); + assert.equal(resolveCalls, 1); + assert.equal(launchCalls, 0); +}); + +test('connectAndResolveViewId: cold start, no socket -> launches and resolves, no forward-launch', async () => { + let ensureCalled = false; + let launchForFileOpenCalls = 0; + + const viewId = await connectAndResolveViewId({ + socketAlreadyExists: false, + ensureSocketRunning: async () => { ensureCalled = true; return true; }, + resolveViewId: async () => 3, + launchForFileOpen: () => { launchForFileOpenCalls++; }, + sleepMs: async () => {}, + }); + + assert.equal(viewId, 3); + assert.equal(ensureCalled, true); + assert.equal(launchForFileOpenCalls, 0); +}); + +test('connectAndResolveViewId: cold start, ensureSocketRunning fails -> -1', async () => { + const viewId = await connectAndResolveViewId({ + socketAlreadyExists: false, + ensureSocketRunning: async () => false, + resolveViewId: async () => 5, // must not be reached + launchForFileOpen: () => {}, + sleepMs: async () => {}, + }); + + assert.equal(viewId, -1); +}); + +test('connectAndResolveViewId: socket exists, file not open yet -> launches for file open, then polls', async () => { + let launchCalls = 0; + let resolveCalls = 0; + + const viewId = await connectAndResolveViewId({ + socketAlreadyExists: true, + ensureSocketRunning: async () => true, + resolveViewId: async () => { resolveCalls++; return resolveCalls < 3 ? -1 : 9; }, + launchForFileOpen: () => { launchCalls++; }, + sleepMs: async () => {}, + maxWaitMs: 10000, + pollIntervalMs: 100, + }); + + assert.equal(viewId, 9); + assert.equal(launchCalls, 1); + assert.equal(resolveCalls, 3); +}); + +test('connectAndResolveViewId: never resolves -> times out, -1', async () => { + let sleepCalls = 0; + + const viewId = await connectAndResolveViewId({ + socketAlreadyExists: true, + ensureSocketRunning: async () => true, + resolveViewId: async () => -1, + launchForFileOpen: () => {}, + sleepMs: async () => { sleepCalls++; }, + maxWaitMs: 1000, + pollIntervalMs: 200, + }); + + assert.equal(viewId, -1); + assert.equal(sleepCalls, 5); // 1000/200 +}); + +test('connectAndResolveViewId: sleepMs receives the poll interval', async () => { + const sleptFor = []; + + await connectAndResolveViewId({ + socketAlreadyExists: true, + ensureSocketRunning: async () => true, + resolveViewId: async () => -1, + launchForFileOpen: () => {}, + sleepMs: async (ms) => { sleptFor.push(ms); }, + maxWaitMs: 600, + pollIntervalMs: 150, + }); + + assert.equal(sleptFor.length, 4); // 600/150 + for (const ms of sleptFor) assert.equal(ms, 150); +});