Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b2589b1d5
|
||
|
|
b4387881c8 | ||
|
|
7e6667aaff | ||
|
|
d5979d7308
|
||
|
|
a40d2ad5ed
|
||
|
|
a590ae47d1
|
No files matched your search
@@ -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`).
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user