Compare commits

..
Author SHA1 Message Date
james.maesandClaude Opus 5.5 5b2589b1d5 fix(widgets): readable dates, and a used approval is not "Not approved"
Needs Attention and Proposed Publication printed raw ISO instants; they
now read like "Oct 14, 2026 4:53 PM UTC". Proposed Publication said
"Not approved" for content whose approval an execution had consumed; it
now says who approved it and that an execution used it.

Found in the demo-server screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:04:59 -05:00
james.maes b4387881c8 Merge pull request #19 from QRun-IO/feature/help-bundle
feat(help): help bundle (qqq#1198 format) with a coverage gate
2026-10-14 11:56:30 -05:00
james.maes 7e6667aaff Merge pull request #17 from QRun-IO/feature/crm-table-names
feat(crm): qbit-crm 0.2.0 crm_* table names
2026-10-14 11:56:19 -05:00
james.maesandClaude Opus 5.5 d5979d7308 docs(help): marketing_agent_run.heartbeatAt (from #18)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:53:54 -05:00
james.maes a40d2ad5ed Merge remote-tracking branch 'origin/develop' into feature/help-bundle 2026-10-14 11:53:03 -05:00
james.maesandClaude Opus 5.5 a590ae47d1 feat(help): help bundle for every marketing app, table, field, action, widget and value source
help/qbit-marketing.yaml in the qqq#1198 bundle format (layer QBIT, 346
entries), with live {{values:}}, {{label:}} and {{maxLength:}} references.
HelpBundleCoverageTest is the stand-in coverage gate until core's
HelpBundleAssertions ships; registration follows with qqq#1198.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:48:44 -05:00
6 changed files with 1867 additions and 7 deletions

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ All notable changes to this project are documented here. The format follows Keep
- Upgrade QQQ to `4.1.0-RC.6`; enable architecture enforcement and strict UI API routes.
### Added
- Help bundle `help/qbit-marketing.yaml` (346 entries) covering the app, tables, fields, actions, widgets and possible-value sources, with `HelpBundleCoverageTest` as the coverage gate until `HelpBundleAssertions` ships in core (qqq#1198). The producer registers it once that API exists.
- Product-contingent possible values for editable campaign, claim, finding, metric and outcome-event references.
- Repository scaffold: build, CI and the repository standard files.
- **Model:** 18 standard `marketing_*` tables, 21 enum possible-value sources, joins and child lists. The PostgreSQL reference DDL (`docs/ddl/postgres.sql`) and an H2 twin are generated from one spec (`scripts/generate-model.py`).
+6
View File
@@ -98,6 +98,12 @@ Set any existing Marketing Policy provider override to `openai-compatible` as we
- Content: Proposed Publication.
- **Actions:** every action is a streamed-ETL record action (record input, standard Review and Result summary lines) in the table's or record's Actions menu. None are nav items.
## Help
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.
The bundle is loaded with `HelpBundles.register(qInstance, "help/qbit-marketing.yaml")` once qqq#1198 ships. The producer will register it then; until that core API exists, nothing registers it. `HelpBundleCoverageTest` checks that the bundle is complete, that every key and reference resolves, and that summaries stay short and plain.
## Email through qbit-email
When the optional [qbit-email](https://github.com/QRun-IO/qbit-email) qbit is produced in the same instance, email channels named `qqq_email` (the starter-data channel) send through it automatically, unless the host registered its own transport under that name. `QqqMessagingEmailTransport` stays the fallback; without qbit-email nothing changes.
@@ -19,10 +19,13 @@ package com.kingsrook.qbits.marketing.widgets;
import java.io.Serializable;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.kingsrook.qbits.marketing.MarketingRuntime;
import com.kingsrook.qbits.marketing.MarketingStore;
@@ -66,6 +69,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
@@ -91,6 +95,8 @@ public final class MarketingWidgets
public static final String CAMPAIGN_RESULTS = "marketing_campaign_results";
public static final String CONTENT_PROPOSAL = "marketing_content_proposal";
private static final DateTimeFormatter WHEN_FORMAT = DateTimeFormatter.ofPattern("MMM d, yyyy h:mm a 'UTC'", Locale.US).withZone(ZoneOffset.UTC);
public static final List<String> HOME_WIDGETS = List.of(NEEDS_ATTENTION, CAMPAIGNS, AGENT_ACTIVITY, CONFIGURATION);
@@ -196,6 +202,17 @@ public final class MarketingWidgets
/*******************************************************************************
** A date-time for people: "Oct 14, 2026 4:53 PM UTC" (empty when null).
*******************************************************************************/
static String when(Object value)
{
Instant instant = ValueUtils.getValueAsInstant(value);
return (instant == null ? "" : WHEN_FORMAT.format(instant));
}
/*******************************************************************************
**
*******************************************************************************/
@@ -242,27 +259,27 @@ public final class MarketingWidgets
for(QRecord r : MarketingStore.query(MarketingContent.TABLE_NAME, new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.EQUALS, ContentStatus.IN_REVIEW.getId())).withLimit(25)))
{
rows.add(row("item", link(MarketingContent.TABLE_NAME, r.getValue("id"), r.getValueString("title")), "what", "Waiting for review (revision " + r.getValueInteger("revisionNumber") + ")",
"since", String.valueOf(r.getValue("modifyDate"))));
"since", when(r.getValue("modifyDate"))));
}
for(QRecord r : MarketingStore.query(MarketingExecution.TABLE_NAME, new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.IN,
List.of(ExecutionStatus.BLOCKED.getId(), ExecutionStatus.NEEDS_RECONCILIATION.getId(), ExecutionStatus.FAILED.getId()))).withLimit(25)))
{
rows.add(row("item", link(MarketingExecution.TABLE_NAME, r.getValue("id"), "Execution #" + r.getValue("id")),
"what", ExecutionStatus.getById(r.getValueInteger("status")).getLabel() + ": " + StringUtils.safeTruncate(r.getValueString("statusDetail"), 160, "..."),
"since", String.valueOf(r.getValue("modifyDate"))));
"since", when(r.getValue("modifyDate"))));
}
for(QRecord r : MarketingStore.query(MarketingFinding.TABLE_NAME, new QQueryFilter(
new QFilterCriteria("status", QCriteriaOperator.EQUALS, FindingStatus.OPEN.getId()),
new QFilterCriteria("severity", QCriteriaOperator.EQUALS, FindingSeverity.BLOCKER.getId())).withLimit(25)))
{
rows.add(row("item", link(MarketingFinding.TABLE_NAME, r.getValue("id"), r.getValueString("title")), "what", "Open blocker finding", "since", String.valueOf(r.getValue("createDate"))));
rows.add(row("item", link(MarketingFinding.TABLE_NAME, r.getValue("id"), r.getValueString("title")), "what", "Open blocker finding", "since", when(r.getValue("createDate"))));
}
for(QRecord r : MarketingStore.query(MarketingAgentRun.TABLE_NAME, new QQueryFilter(
new QFilterCriteria("status", QCriteriaOperator.EQUALS, AgentRunStatus.FAILED.getId()),
new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN, Instant.now().minus(7, ChronoUnit.DAYS))).withLimit(10)))
{
rows.add(row("item", link(MarketingAgentRun.TABLE_NAME, r.getValue("id"), "Agent run #" + r.getValue("id")),
"what", "Failed: " + StringUtils.safeTruncate(r.getValueString("errorMessage"), 160, "..."), "since", String.valueOf(r.getValue("createDate"))));
"what", "Failed: " + StringUtils.safeTruncate(r.getValueString("errorMessage"), 160, "..."), "since", when(r.getValue("createDate"))));
}
return (new RenderWidgetOutput(table(List.of(column("Item", "item", true), column("What", "what", false), column("Since", "since", false)), rows,
@@ -438,9 +455,25 @@ public final class MarketingWidgets
new QFilterCriteria("contentId", QCriteriaOperator.EQUALS, contentId),
new QFilterCriteria("status", QCriteriaOperator.EQUALS, ApprovalStatus.ACTIVE.getId())));
String approval = active.isEmpty() ? "Not approved."
: "Approved by " + escape(active.get(0).getValueString("approvedByUserId")) + " at " + active.get(0).getValue("approvedAt")
String approval;
if(!active.isEmpty())
{
approval = "Approved by " + escape(active.get(0).getValueString("approvedByUserId")) + " on " + when(active.get(0).getValue("approvedAt"))
+ (content.getContentHash() != null && content.getContentHash().equals(active.get(0).getValueString("contentHash")) ? " (matches this revision)." : " (does NOT match this revision).");
}
else
{
//////////////////////////////////////////////////////////////////////
// an approval an execution used up is not "not approved": say so //
//////////////////////////////////////////////////////////////////////
List<QRecord> consumed = MarketingStore.query(MarketingApproval.TABLE_NAME, new QQueryFilter(
new QFilterCriteria("contentId", QCriteriaOperator.EQUALS, contentId),
new QFilterCriteria("status", QCriteriaOperator.EQUALS, ApprovalStatus.CONSUMED.getId()),
new QFilterCriteria("contentHash", QCriteriaOperator.EQUALS, content.getContentHash())));
approval = consumed.isEmpty() ? "Not approved."
: "Approved by " + escape(consumed.get(0).getValueString("approvedByUserId")) + " on " + when(consumed.get(0).getValue("approvedAt"))
+ ", and used by an execution. Approve again to send this revision again.";
}
StringBuilder html = new StringBuilder("<div style=\"max-width:100%;overflow-wrap:anywhere\">");
html.append("<p><strong>").append(escape(content.getTitle())).append("</strong></p>");
@@ -450,7 +483,7 @@ public final class MarketingWidgets
item(html, "Call to action", content.getCallToAction());
item(html, "Destination", content.getDestinationUrl());
item(html, "Recipients", StringUtils.hasContent(content.getRecipientScope()) ? content.getRecipientScope() : "public (no recipient list)");
item(html, "Scheduled for", content.getScheduledFor() == null ? "(not set)" : content.getScheduledFor().toString());
item(html, "Scheduled for", content.getScheduledFor() == null ? "(not set)" : when(content.getScheduledFor()));
item(html, "Revision", content.getRevisionNumber() + " by " + label(AuthorType.getById(content.getAuthorType())));
item(html, "Content hash", content.getContentHash() == null ? "" : content.getContentHash().substring(0, 12));
item(html, "Status", label(ContentStatus.getById(content.getStatus())));
File diff suppressed because it is too large. Load diff
@@ -253,6 +253,13 @@ class EndToEndFlowTest extends BaseMarketingTest
assertThat(deliveries.get(0).getValueString("body")).as("exactly the approved text").isEqualTo(body);
assertThat(reload(MarketingApproval.TABLE_NAME, approval.getValueInteger("id")).getValueInteger("status")).isEqualTo(ApprovalStatus.CONSUMED.getId());
assertThat(reload(MarketingContent.TABLE_NAME, contentId).getValueInteger("status")).isEqualTo(ContentStatus.ACCEPTED.getId());
com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput proposalInput = new com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput()
.withWidgetMetaData(com.kingsrook.qqq.backend.core.context.QContext.getQInstance().getWidget(com.kingsrook.qbits.marketing.widgets.MarketingWidgets.CONTENT_PROPOSAL));
proposalInput.setQueryParams(new java.util.HashMap<>(java.util.Map.of("id", String.valueOf(contentId))));
String proposal = ((com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML) new com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction()
.execute(proposalInput).getWidgetData()).getHtml();
assertThat(proposal).as("a used-up approval is not shown as not approved").contains("used by an execution").doesNotContain("Not approved")
.containsPattern("[A-Z][a-z]{2} [0-9]{1,2}, [0-9]{4} [0-9]{1,2}:[0-9]{2} [AP]M UTC");
///////////////////////////////////////////////////////////////////
// 7. recorded outcomes: the provider acceptance (test data) and //
@@ -0,0 +1,421 @@
/*
* 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;
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.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 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.
*******************************************************************************/
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.
*******************************************************************************/
@Test
void testHeader() throws IOException
{
Map<String, Object> bundle = loadBundle();
assertThat(bundle.get("bundle")).isEqualTo("qbit-marketing");
assertThat(bundle.get("layer")).isEqualTo("QBIT");
assertThat(bundle.get("version")).isNotNull();
}
/*******************************************************************************
** Every object the qbit owns has an entry.
*******************************************************************************/
@Test
void testEveryOwnedObjectHasAnEntry() throws IOException
{
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);
}
/*******************************************************************************
** Every entry key names something that exists.
*******************************************************************************/
@Test
void testEveryKeyResolves() throws IOException
{
List<String> problems = new ArrayList<>();
for(Entry entry : loadEntries())
{
String problem = resolveKey(entry.key());
if(problem != null)
{
problems.add(entry.key() + ": " + problem);
}
}
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())
{
String summary = entry.summary();
if(summary == null || summary.isBlank())
{
problems.add(entry.key() + ": no summary");
continue;
}
if(summary.length() > MAX_SUMMARY_LENGTH)
{
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");
}
}
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();
}
}