Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8425e6c673
|
||
|
|
39c86cdcae | ||
|
|
ec6e382b69
|
||
|
|
f4d3f2c105
|
||
|
|
5d687b37a7
|
||
|
|
a369e3c7b0
|
||
|
|
9e465d7b7c
|
||
|
|
fe4970e10c
|
||
|
|
f90a1192b4
|
||
|
|
4c79c330b4
|
||
|
|
9b01e6087c
|
||
|
|
5eabd24f9b
|
||
|
|
fd061f6e8c
|
||
|
|
4849764e2d | ||
|
|
4761a0242b | ||
|
|
1bb2371d75 | ||
|
|
a01ad9487f |
No files matched your search
@@ -1,12 +1,12 @@
|
||||
version: 2.1
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.12
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: 0.7.12
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
profile: qbit
|
||||
orb_version: 0.7.12
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
|
||||
### Added
|
||||
- Check Readiness warns when a channel's `rulesVerifiedDate` is older than the new `staleChannelRulesDays` (default 90), on a campaign whose content uses the channel, like `staleSourceDays` does for evidence sources. The warning, and the "rules not verified" one, now quote the channel's `rulesSourceUrl`. The agent's campaign view also shows the lead qualification note.
|
||||
- The help bundle is now applied to the app and tables at run time (`MarketingHelp`, a stand-in for the core registry), so it shows when a table is empty. Every table says "How rows get created" and names **Load Verified Starter Data** where it seeds the table. The bundle version is `1`.
|
||||
- The help bundle is registered with the QQQ core help registry (`HelpBundles.register`, qqq#1198), replacing the `MarketingHelp` stand-in, so it shows when a table is empty on hosts with core help content. `HelpBundleCoverageTest` uses core `HelpBundleAssertions`. Every table says "How rows get created" and names **Load Verified Starter Data** where it seeds the table. The bundle version is `1`.
|
||||
- `MarketingTablesGuardrailTest` and `ChannelRulesStalenessTest`.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -102,7 +102,7 @@ Set any existing Marketing Policy provider override to `openai-compatible` as we
|
||||
|
||||
The help text ships with the code it describes, as a QQQ help bundle at `help/qbit-marketing.yaml` (a classpath resource, layer `QBIT`). It has 346 entries: the Marketing app, every `marketing_*` table and each of its non-trivial fields, every record and table action, every widget (the dashboard widgets and the child lists) and every possible-value source. Each entry has a one-line summary for the tooltip and a few sentences of plain help. Labels, maximum lengths and enum values are `{{...}}` references, so the help does not repeat what the metadata already says.
|
||||
|
||||
Until the core registry (qqq#1198) ships, `MarketingHelp` (marked `STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8)`) applies the app and table entries to the metadata at the end of the producer, resolving the `{{...}}` references, so the table help shows when a table is empty. It only fills an empty help slot, so a host's own help wins. When the core API exists the producer will register the bundle and `MarketingHelp` goes away. Every table entry says **How rows get created** and names the **Load Verified Starter Data** action (Marketing Product) for the eight tables it seeds. `HelpBundleCoverageTest` checks that the bundle is complete, that every key and reference resolves, and that summaries stay short and plain.
|
||||
The producer registers the bundle with the QQQ core help registry (`HelpBundles.register`, qqq#1198). Registration is metadata-only; on hosts that install core help content (`HelpContentMetaDataProvider`), core's startup sync writes the entries to `helpContent` and core resolves the `{{...}}` references when it serves them, so the table help shows when a table is empty. Because the bundle is at the `QBIT` layer, a host bundle or host-edited help for the same key wins. Every table entry says **How rows get created** and names the **Load Verified Starter Data** action (Marketing Product) for the eight tables it seeds. `HelpBundleCoverageTest` runs core's `HelpBundleAssertions` gate (complete coverage, every key and reference resolves, short plain summaries) and also checks widget and possible-value-source coverage.
|
||||
|
||||
## Starter data and setup
|
||||
|
||||
|
||||
@@ -18,22 +18,23 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
<!-- The provider SPI (com.kingsrook.qbits.assistant.spi) only: no assistant runtime. -->
|
||||
<qbit-assistant.version>0.1.0-RC.12</qbit-assistant.version>
|
||||
<qbit-assistant.version>0.1.0-RC.25</qbit-assistant.version>
|
||||
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
|
||||
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
|
||||
<qbit-email.version>0.1.0-RC.3</qbit-email.version>
|
||||
<qbit-email.version>0.1.0-RC.11</qbit-email.version>
|
||||
<h2.version>2.3.232</h2.version>
|
||||
</properties>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.1.2</version>
|
||||
<version>2.2.1</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -208,7 +209,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -23,7 +23,6 @@ import java.util.List;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qbits.marketing.agent.KernelToolRegistration;
|
||||
import com.kingsrook.qbits.marketing.agent.tools.MarketingTools;
|
||||
import com.kingsrook.qbits.marketing.metadata.MarketingHelp;
|
||||
import com.kingsrook.qbits.marketing.metadata.MarketingMetaDataBuilder;
|
||||
import com.kingsrook.qbits.marketing.processes.MarketingActions;
|
||||
import com.kingsrook.qbits.marketing.processes.MarketingTableProcesses;
|
||||
@@ -33,6 +32,7 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
@@ -71,6 +71,7 @@ public class MarketingQBitProducer implements QBitMetaDataProducer<MarketingQBit
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-marketing";
|
||||
public static final String VERSION = loadVersion();
|
||||
public static final String HELP_BUNDLE = "help/qbit-marketing.yaml";
|
||||
|
||||
private MarketingQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
@@ -201,7 +202,11 @@ public class MarketingQBitProducer implements QBitMetaDataProducer<MarketingQBit
|
||||
}
|
||||
|
||||
qInstance.addApp(builder.buildApp(MarketingWidgets.HOME_WIDGETS));
|
||||
MarketingHelp.apply(qInstance);
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// help: register the bundle with core; registration is metadata-only and //
|
||||
// core's startup sync applies it on hosts that have the helpContent table //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
HelpBundles.register(qInstance, HELP_BUNDLE, MarketingQBitProducer.class.getClassLoader());
|
||||
|
||||
////////////////////////////////////
|
||||
// runtime and kernel tools //
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.marketing.metadata;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Applies help/qbit-marketing.yaml (the QQQ help-bundle format) to the
|
||||
** Marketing app and its marketing_* tables: what each table is for and
|
||||
** how its rows get created, shown on the table screen and when it is empty.
|
||||
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with HelpBundles.register once the pinned
|
||||
** QQQ release includes it; the bundle file stays the same.
|
||||
*******************************************************************************/
|
||||
public final class MarketingHelp
|
||||
{
|
||||
public static final String RESOURCE = "/help/qbit-marketing.yaml";
|
||||
public static final String SLOT = "help";
|
||||
|
||||
private static final Pattern REFERENCE = Pattern.compile("\\{\\{([A-Za-z]+):([^}]*)}}");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private MarketingHelp()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle's entries by key, validated (fails startup if malformed).
|
||||
*******************************************************************************/
|
||||
public static Map<String, JsonNode> entries() throws QException
|
||||
{
|
||||
try(InputStream stream = MarketingHelp.class.getResourceAsStream(RESOURCE))
|
||||
{
|
||||
if(stream == null)
|
||||
{
|
||||
throw (new IOException("missing resource"));
|
||||
}
|
||||
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
|
||||
if(!"qbit-marketing".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
|
||||
{
|
||||
throw (new IOException("invalid bundle header"));
|
||||
}
|
||||
Map<String, JsonNode> entries = new LinkedHashMap<>();
|
||||
for(JsonNode entry : bundle.path("entries"))
|
||||
{
|
||||
String key = entry.path("key").asText();
|
||||
String summary = entry.path("summary").asText();
|
||||
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
|
||||
{
|
||||
throw (new IOException("invalid or duplicate entry: " + key));
|
||||
}
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
throw (new QException("Could not load " + RESOURCE, e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the help to the app and every marketing_* table in the instance.
|
||||
** A host's own help on a table is kept (the bundle only fills the slot).
|
||||
*******************************************************************************/
|
||||
public static void apply(QInstance instance) throws QException
|
||||
{
|
||||
Map<String, JsonNode> entries = entries();
|
||||
for(QTableMetaData table : instance.getTables().values())
|
||||
{
|
||||
JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT);
|
||||
if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
table.withHelpContent(SLOT, content(instance, entry));
|
||||
}
|
||||
}
|
||||
QAppMetaData app = instance.getApp(MarketingMetaDataBuilder.APP_NAME);
|
||||
JsonNode appEntry = entries.get("app:marketing;slot:" + SLOT);
|
||||
if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
app.withHelpContent(SLOT, content(instance, appEntry));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary as the first paragraph, then the content, as markdown.
|
||||
*******************************************************************************/
|
||||
static QHelpContent content(QInstance instance, JsonNode entry)
|
||||
{
|
||||
return (new QHelpContent().withContentAsMarkdown(resolve(instance, entry.path("summary").asText().strip()) + "\n\n" + resolve(instance, entry.path("content").asText().strip())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the bundle's {{label:..}}, {{default:..}}, {{maxLength:..}} and
|
||||
** {{values:..}} references against the instance, so the text is plain.
|
||||
*******************************************************************************/
|
||||
static String resolve(QInstance instance, String text)
|
||||
{
|
||||
Matcher matcher = REFERENCE.matcher(text);
|
||||
StringBuilder out = new StringBuilder();
|
||||
while(matcher.find())
|
||||
{
|
||||
matcher.appendReplacement(out, Matcher.quoteReplacement(referenceText(instance, matcher.group(1), matcher.group(2))));
|
||||
}
|
||||
matcher.appendTail(out);
|
||||
return (out.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One reference's text; unresolvable ones keep their target name.
|
||||
*******************************************************************************/
|
||||
private static String referenceText(QInstance instance, String kind, String target)
|
||||
{
|
||||
String[] tableAndField = target.split("\\.", 2);
|
||||
QTableMetaData table = instance.getTable(tableAndField[0]);
|
||||
QFieldMetaData field = table == null || tableAndField.length < 2 ? null : table.getFields().get(tableAndField[1]);
|
||||
switch(kind)
|
||||
{
|
||||
case "label":
|
||||
if(field != null)
|
||||
{
|
||||
return (field.getLabel());
|
||||
}
|
||||
if(table != null)
|
||||
{
|
||||
return (table.getLabel());
|
||||
}
|
||||
if(instance.getProcess(target) != null)
|
||||
{
|
||||
return (instance.getProcess(target).getLabel());
|
||||
}
|
||||
return (instance.getApp(target) != null ? instance.getApp(target).getLabel() : target);
|
||||
case "default":
|
||||
return (field == null || field.getDefaultValue() == null ? target : String.valueOf(field.getDefaultValue()));
|
||||
case "maxLength":
|
||||
return (field == null || field.getMaxLength() == null ? target : String.valueOf(field.getMaxLength()));
|
||||
case "values":
|
||||
QPossibleValueSource source = instance.getPossibleValueSource(target);
|
||||
return (source == null || source.getEnumValues() == null ? target : source.getEnumValues().stream().map(value -> String.valueOf(value.getLabel())).collect(Collectors.joining(", ")));
|
||||
default:
|
||||
return (target);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,404 +17,108 @@
|
||||
package com.kingsrook.qbits.marketing;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import com.kingsrook.qbits.marketing.metadata.MarketingMetaDataBuilder;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
import com.kingsrook.qqq.backend.core.actions.help.HelpTarget;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundle;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundleEntry;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundleLayer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with
|
||||
** HelpBundleAssertions.forInstance(qInstance).bundles("help/qbit-marketing.yaml").owning(...).assertComplete()
|
||||
** once qqq#1198 ships.
|
||||
**
|
||||
** The help bundle (help/qbit-marketing.yaml) covers everything the qbit
|
||||
** produces, and every entry is sound:
|
||||
** - the app, every marketing_* table, every non-trivial field (all but the
|
||||
** primary key, createDate, modifyDate and hidden fields), every process,
|
||||
** every widget and every possible-value source has an entry;
|
||||
** - every entry key resolves to a real object;
|
||||
** - summaries are at most 120 characters, with no markup;
|
||||
** - every {{...}} reference resolves.
|
||||
**
|
||||
** Each test fails with the full list of missing or broken keys.
|
||||
** produces, and every entry is sound. Core's HelpBundleAssertions checks
|
||||
** that the app, every marketing_* table, every non-trivial field and every
|
||||
** process has help, that every key and {{...}} reference resolves and that
|
||||
** summaries are short plain text. Core does not require widget or
|
||||
** possible-value-source coverage, so those are checked here too. QQQ's
|
||||
** generated bulk table processes are core's to document and are not owned.
|
||||
*******************************************************************************/
|
||||
class HelpBundleCoverageTest extends BaseMarketingTest
|
||||
{
|
||||
private static final String BUNDLE_RESOURCE = "help/qbit-marketing.yaml";
|
||||
|
||||
private static final int MAX_SUMMARY_LENGTH = 120;
|
||||
private static final String MARKUP_CHARACTERS = "<>*_`[]#";
|
||||
|
||||
private static final Pattern REFERENCE = Pattern.compile("\\{\\{([A-Za-z]+):([^}]*)}}");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One bundle entry.
|
||||
*******************************************************************************/
|
||||
private record Entry(String key, String summary, String content)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle header names this qbit, at its layer.
|
||||
** The producer registers exactly this qbit's bundle, at the QBIT layer.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testHeader() throws IOException
|
||||
void testRegisteredBundle()
|
||||
{
|
||||
Map<String, Object> bundle = loadBundle();
|
||||
assertThat(bundle.get("bundle")).isEqualTo("qbit-marketing");
|
||||
assertThat(bundle.get("layer")).isEqualTo("QBIT");
|
||||
assertThat(bundle.get("version")).isNotNull();
|
||||
assertThat(HelpBundles.of(qInstance).getBundles()).singleElement().satisfies(bundle ->
|
||||
{
|
||||
assertThat(bundle.getId()).isEqualTo("qbit-marketing");
|
||||
assertThat(bundle.getLayer()).isEqualTo(HelpBundleLayer.QBIT);
|
||||
assertThat(bundle.getVersion()).isEqualTo(MarketingQBitProducer.VERSION);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every object the qbit owns has an entry.
|
||||
** Core coverage gate over everything the qbit owns.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEveryOwnedObjectHasAnEntry() throws IOException
|
||||
void testCoreCoverageGate()
|
||||
{
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
List<String> problems = new ArrayList<>();
|
||||
for(Entry entry : loadEntries())
|
||||
{
|
||||
if(!keys.add(entry.key()))
|
||||
{
|
||||
problems.add("duplicate entry: " + entry.key());
|
||||
}
|
||||
}
|
||||
|
||||
for(String expected : expectedKeys())
|
||||
{
|
||||
if(!keys.contains(expected))
|
||||
{
|
||||
problems.add("missing entry: " + expected);
|
||||
}
|
||||
}
|
||||
assertNoProblems(problems);
|
||||
HelpBundleAssertions.forInstance(qInstance)
|
||||
.bundles(MarketingQBitProducer.HELP_BUNDLE)
|
||||
.owning(HelpBundleCoverageTest::ownedByMarketing)
|
||||
.assertComplete();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every entry key names something that exists.
|
||||
** The qbit owns its app, marketing_* objects and the processes it produces.
|
||||
** QQQ's generated bulk table processes (no source qbit) are core's to document.
|
||||
*******************************************************************************/
|
||||
private static boolean ownedByMarketing(HelpTarget target)
|
||||
{
|
||||
if(target.owner() instanceof QProcessMetaData process && process.getSourceQBitName() == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (target.name().equals("marketing") || target.name().startsWith("marketing_") || target.name().startsWith("Marketing"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every widget and possible-value source has an entry, and every entry has
|
||||
** a summary and content.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEveryKeyResolves() throws IOException
|
||||
void testWidgetsSourcesAndContent()
|
||||
{
|
||||
List<String> problems = new ArrayList<>();
|
||||
for(Entry entry : loadEntries())
|
||||
Map<HelpBundles.EntryKey, HelpBundles.WinningEntry> entries = HelpBundles.of(qInstance).resolveWinningEntries();
|
||||
List<String> problems = new ArrayList<>();
|
||||
TreeSet<String> expected = new TreeSet<>();
|
||||
qInstance.getWidgets().keySet().stream().filter(n -> n.startsWith("marketing_")).forEach(n -> expected.add("widget:" + n + ";slot:label"));
|
||||
qInstance.getPossibleValueSources().keySet().stream().filter(n -> n.startsWith("marketing_")).forEach(n -> expected.add("possibleValueSource:" + n + ";slot:help"));
|
||||
for(String key : expected)
|
||||
{
|
||||
String problem = resolveKey(entry.key());
|
||||
if(problem != null)
|
||||
if(!entries.containsKey(new HelpBundles.EntryKey(key, "ALL_SCREENS")))
|
||||
{
|
||||
problems.add(entry.key() + ": " + problem);
|
||||
problems.add("missing entry: " + key);
|
||||
}
|
||||
}
|
||||
assertNoProblems(problems);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summaries are short, plain text; every entry has content.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSummariesAndContent() throws IOException
|
||||
{
|
||||
List<String> problems = new ArrayList<>();
|
||||
for(Entry entry : loadEntries())
|
||||
HelpBundle bundle = HelpBundles.of(qInstance).getBundles().getFirst();
|
||||
for(HelpBundleEntry entry : bundle.getEntries())
|
||||
{
|
||||
String summary = entry.summary();
|
||||
if(summary == null || summary.isBlank())
|
||||
if(entry.getSummary() == null || entry.getSummary().isBlank())
|
||||
{
|
||||
problems.add(entry.key() + ": no summary");
|
||||
continue;
|
||||
problems.add(entry.getKey() + ": no summary");
|
||||
}
|
||||
if(summary.length() > MAX_SUMMARY_LENGTH)
|
||||
if(entry.getContent() == null || entry.getContent().isBlank())
|
||||
{
|
||||
problems.add(entry.key() + ": summary is " + summary.length() + " characters (limit " + MAX_SUMMARY_LENGTH + ")");
|
||||
}
|
||||
for(char c : MARKUP_CHARACTERS.toCharArray())
|
||||
{
|
||||
if(summary.indexOf(c) >= 0)
|
||||
{
|
||||
problems.add(entry.key() + ": summary contains the markup character " + c);
|
||||
}
|
||||
}
|
||||
if(entry.content() == null || entry.content().isBlank())
|
||||
{
|
||||
problems.add(entry.key() + ": no content");
|
||||
problems.add(entry.getKey() + ": no content");
|
||||
}
|
||||
}
|
||||
assertNoProblems(problems);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every {{...}} reference resolves.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEveryReferenceResolves() throws IOException
|
||||
{
|
||||
List<String> problems = new ArrayList<>();
|
||||
for(Entry entry : loadEntries())
|
||||
{
|
||||
for(String text : new String[] { entry.summary(), entry.content() })
|
||||
{
|
||||
if(text == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Matcher matcher = REFERENCE.matcher(text);
|
||||
while(matcher.find())
|
||||
{
|
||||
String problem = resolveReference(matcher.group(1), matcher.group(2));
|
||||
if(problem != null)
|
||||
{
|
||||
problems.add(entry.key() + ": {{" + matcher.group(1) + ":" + matcher.group(2) + "}} " + problem);
|
||||
}
|
||||
}
|
||||
if(text.replaceAll(REFERENCE.pattern(), "").contains("{{"))
|
||||
{
|
||||
problems.add(entry.key() + ": malformed {{ reference");
|
||||
}
|
||||
}
|
||||
}
|
||||
assertNoProblems(problems);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The keys the bundle must contain, from the produced instance.
|
||||
*******************************************************************************/
|
||||
private Set<String> expectedKeys()
|
||||
{
|
||||
Set<String> keys = new TreeSet<>();
|
||||
keys.add("app:" + MarketingMetaDataBuilder.APP_NAME + ";slot:help");
|
||||
|
||||
for(QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
if(!table.getName().startsWith("marketing_"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
keys.add("table:" + table.getName() + ";slot:help");
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
if(isNonTrivial(table, field))
|
||||
{
|
||||
keys.add("table:" + table.getName() + ";field:" + field.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// the qbit's own processes (QQQ also generates bulk table processes) //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
for(QProcessMetaData process : qInstance.getProcesses().values())
|
||||
{
|
||||
if(process.getSourceQBitName() != null)
|
||||
{
|
||||
keys.add("process:" + process.getName() + ";slot:help");
|
||||
}
|
||||
}
|
||||
|
||||
qInstance.getWidgets().keySet().stream().filter(n -> n.startsWith("marketing_")).forEach(n -> keys.add("widget:" + n + ";slot:label"));
|
||||
qInstance.getPossibleValueSources().keySet().stream().filter(n -> n.startsWith("marketing_")).forEach(n -> keys.add("possibleValueSource:" + n + ";slot:help"));
|
||||
return (keys);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every field except the primary key, createDate, modifyDate and hidden ones.
|
||||
*******************************************************************************/
|
||||
private static boolean isNonTrivial(QTableMetaData table, QFieldMetaData field)
|
||||
{
|
||||
String name = field.getName();
|
||||
return (!name.equals(table.getPrimaryKeyField()) && !name.equals("createDate") && !name.equals("modifyDate") && !Boolean.TRUE.equals(field.getIsHidden()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Null if the key names a real object, else why not.
|
||||
*******************************************************************************/
|
||||
private String resolveKey(String key)
|
||||
{
|
||||
Map<String, String> parts = new LinkedHashMap<>();
|
||||
for(String part : key.split(";"))
|
||||
{
|
||||
int colon = part.indexOf(':');
|
||||
if(colon < 1)
|
||||
{
|
||||
return ("malformed key part [" + part + "]");
|
||||
}
|
||||
parts.put(part.substring(0, colon), part.substring(colon + 1));
|
||||
}
|
||||
|
||||
if(parts.containsKey("app"))
|
||||
{
|
||||
return (qInstance.getApp(parts.get("app")) == null ? "no such app" : slotProblem(parts, "help"));
|
||||
}
|
||||
if(parts.containsKey("table"))
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(parts.get("table"));
|
||||
if(table == null)
|
||||
{
|
||||
return ("no such table");
|
||||
}
|
||||
if(parts.containsKey("field"))
|
||||
{
|
||||
return (table.getFields().containsKey(parts.get("field")) ? null : "no such field");
|
||||
}
|
||||
if(parts.containsKey("section"))
|
||||
{
|
||||
return (table.getSections() != null && table.getSections().stream().map(QFieldSection::getName).anyMatch(parts.get("section")::equals) ? null : "no such section");
|
||||
}
|
||||
return (slotProblem(parts, "help"));
|
||||
}
|
||||
if(parts.containsKey("process"))
|
||||
{
|
||||
QProcessMetaData process = qInstance.getProcess(parts.get("process"));
|
||||
if(process == null)
|
||||
{
|
||||
return ("no such process");
|
||||
}
|
||||
if(parts.containsKey("step"))
|
||||
{
|
||||
return (process.getStep(parts.get("step")) == null ? "no such step" : null);
|
||||
}
|
||||
return (parts.containsKey("field") ? "process fields are not covered by this bundle" : slotProblem(parts, "help"));
|
||||
}
|
||||
if(parts.containsKey("widget"))
|
||||
{
|
||||
return (qInstance.getWidget(parts.get("widget")) == null ? "no such widget" : slotProblem(parts, "label"));
|
||||
}
|
||||
if(parts.containsKey("possibleValueSource"))
|
||||
{
|
||||
return (qInstance.getPossibleValueSource(parts.get("possibleValueSource")) == null ? "no such possible-value source" : slotProblem(parts, "help"));
|
||||
}
|
||||
return ("unsupported key");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The slot must be the expected one.
|
||||
*******************************************************************************/
|
||||
private static String slotProblem(Map<String, String> parts, String expectedSlot)
|
||||
{
|
||||
return (expectedSlot.equals(parts.get("slot")) ? null : "expected slot:" + expectedSlot);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Null if the live reference resolves, else why not.
|
||||
*******************************************************************************/
|
||||
private String resolveReference(String kind, String target)
|
||||
{
|
||||
String[] tableAndField = target.split("\\.", 2);
|
||||
QTableMetaData table = qInstance.getTable(tableAndField[0]);
|
||||
QFieldMetaData field = table == null || tableAndField.length < 2 ? null : table.getFields().get(tableAndField[1]);
|
||||
|
||||
switch(kind)
|
||||
{
|
||||
case "label":
|
||||
{
|
||||
if(tableAndField.length == 2)
|
||||
{
|
||||
return (field == null ? "does not resolve to a table field" : null);
|
||||
}
|
||||
boolean found = table != null || qInstance.getProcess(target) != null || qInstance.getApp(target) != null;
|
||||
return (found ? null : "does not resolve to a table, process or app");
|
||||
}
|
||||
case "default":
|
||||
{
|
||||
return (field == null ? "does not resolve to a table field" : (field.getDefaultValue() == null ? "the field has no default value" : null));
|
||||
}
|
||||
case "maxLength":
|
||||
{
|
||||
return (field == null ? "does not resolve to a table field" : (field.getMaxLength() == null ? "the field has no maximum length" : null));
|
||||
}
|
||||
case "values":
|
||||
{
|
||||
QPossibleValueSource source = qInstance.getPossibleValueSource(target);
|
||||
return (source == null ? "does not resolve to a possible-value source" : (source.getType() == QPossibleValueSourceType.ENUM ? null : "is not an enum source"));
|
||||
}
|
||||
default:
|
||||
{
|
||||
return ("unsupported reference kind");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle, as parsed YAML.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> loadBundle() throws IOException
|
||||
{
|
||||
try(InputStream inputStream = HelpBundleCoverageTest.class.getClassLoader().getResourceAsStream(BUNDLE_RESOURCE))
|
||||
{
|
||||
assertThat(inputStream).as("classpath resource " + BUNDLE_RESOURCE).isNotNull();
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> bundle = new ObjectMapper(new YAMLFactory()).readValue(inputStream, Map.class);
|
||||
return (bundle);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle's entries.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<Entry> loadEntries() throws IOException
|
||||
{
|
||||
List<Entry> entries = new ArrayList<>();
|
||||
for(Map<String, Object> row : (List<Map<String, Object>>) loadBundle().get("entries"))
|
||||
{
|
||||
entries.add(new Entry(String.valueOf(row.get("key")), (String) row.get("summary"), (String) row.get("content")));
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fail with every problem at once.
|
||||
*******************************************************************************/
|
||||
private static void assertNoProblems(List<String> problems)
|
||||
{
|
||||
assertThat(problems).as(problems.size() + " help bundle problem(s):\n" + String.join("\n", problems)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
@@ -26,10 +26,10 @@ import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.marketing.metadata.MarketingHelp;
|
||||
import com.kingsrook.qbits.marketing.metadata.MarketingMetaDataBuilder;
|
||||
import com.kingsrook.qbits.marketing.processes.MarketingTableProcesses;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.HelpTextRenderer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -131,9 +131,8 @@ class MarketingTablesGuardrailTest extends BaseMarketingTest
|
||||
String action = qInstance.getProcess(MarketingTableProcesses.LOAD_STARTER_DATA).getLabel();
|
||||
for(String table : marketingTables())
|
||||
{
|
||||
List<QHelpContent> help = help(table);
|
||||
assertThat(help).as(table + " table help").isNotEmpty();
|
||||
String content = help.getFirst().getContent();
|
||||
String content = help("table:" + table + ";slot:help");
|
||||
assertThat(content).as(table + " table help").isNotBlank();
|
||||
assertThat(content).as(table + " help says how rows get created").contains("How rows get created");
|
||||
assertThat(content).as(table + " help has no unresolved reference").doesNotContain("{{");
|
||||
if(SEEDED_BY_STARTER_DATA.contains(table))
|
||||
@@ -141,36 +140,45 @@ class MarketingTablesGuardrailTest extends BaseMarketingTest
|
||||
assertThat(content).as(table + " help names the starter-data action").contains(action);
|
||||
}
|
||||
}
|
||||
assertThat(qInstance.getApp(MarketingMetaDataBuilder.APP_NAME).getHelpContent()).containsKey(MarketingHelp.SLOT);
|
||||
assertThat(MarketingHelp.entries().keySet()).anyMatch(key -> key.startsWith("table:marketing_content;slot:"));
|
||||
assertThat(help("marketing_content").getFirst().getContent()).as("enum references become the values").contains("Draft");
|
||||
assertThat(help("app:" + MarketingMetaDataBuilder.APP_NAME + ";slot:help")).isNotBlank();
|
||||
assertThat(help("table:marketing_content;slot:help")).as("enum references become the values").contains("Draft");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host's own help on a table is kept.
|
||||
** The bundle is registered at the QBIT layer, so a host bundle for the same
|
||||
** key wins and the host's own help is kept.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void hostHelpIsNotOverridden() throws Exception
|
||||
void hostHelpIsNotOverridden()
|
||||
{
|
||||
qInstance.getTable("marketing_claim").getHelpContent().put(MarketingHelp.SLOT, List.of(new QHelpContent().withContentAsMarkdown("host text")));
|
||||
MarketingHelp.apply(qInstance);
|
||||
assertThat(help("marketing_claim").getFirst().getContent()).isEqualTo("host text");
|
||||
HelpBundles.register(qInstance, "help/test-host-override.yaml");
|
||||
assertThat(help("table:marketing_claim;slot:help")).isEqualTo("host text");
|
||||
assertThat(help("table:marketing_product;slot:help")).contains("How rows get created");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** The winning bundle text for a key (ALL_SCREENS role), rendered by core
|
||||
** against the instance the way the frontend sees it.
|
||||
*******************************************************************************/
|
||||
private List<QHelpContent> help(String table)
|
||||
private String help(String key)
|
||||
{
|
||||
return (qInstance.getTable(table).getHelpContent() == null ? null : qInstance.getTable(table).getHelpContent().get(MarketingHelp.SLOT));
|
||||
HelpBundles.WinningEntry winner = HelpBundles.of(qInstance).resolveWinningEntries().get(new HelpBundles.EntryKey(key, "ALL_SCREENS"));
|
||||
if(winner == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
HelpTextRenderer renderer = new HelpTextRenderer(qInstance);
|
||||
String summary = winner.entry().getSummary() == null ? "" : renderer.render(winner.entry().getSummary().strip());
|
||||
return ((summary + "\n\n" + renderer.render(winner.entry().getContent().strip())).strip());
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
bundle: test-host-override
|
||||
version: "1"
|
||||
layer: HOST
|
||||
entries:
|
||||
- key: table:marketing_claim;slot:help
|
||||
content: host text
|
||||
Reference in new issue
Block a user