diff --git a/src/main/java/com/knowledgepixels/nanodash/Utils.java b/src/main/java/com/knowledgepixels/nanodash/Utils.java
index da79e819..be00c496 100644
--- a/src/main/java/com/knowledgepixels/nanodash/Utils.java
+++ b/src/main/java/com/knowledgepixels/nanodash/Utils.java
@@ -990,7 +990,10 @@ public static String getPageParametersAsString(PageParameters params) {
* @param selectItem the Select2Choice component to set the escape markup for
*/
public static void setSelect2ChoiceMinimalEscapeMarkup(Select2Choice> selectItem) {
- selectItem.getSettings().setEscapeMarkup("function(markup) {" + "return markup" + ".replaceAll('<','<').replaceAll('>', '>')" + ".replace(/^(.*?) - /, '$1
')" + ".replace(/\\((https?:[\\S]+)\\)$/, '
$1')" + ".replace(/^([^<].*)$/, '$1')" + ";}");
+ // The note of a to-be-minted value is not part of the value, so it is set in its own
+ // span and styled as an aside rather than as the term itself (issue #652).
+ String noteRegex = TO_BE_MINTED_NOTE.replace("(", "\\(").replace(")", "\\)");
+ selectItem.getSettings().setEscapeMarkup("function(markup) {" + "return markup" + ".replaceAll('<','<').replaceAll('>', '>')" + ".replace(/^(.*?) - /, '$1
')" + ".replace(/\\((https?:[\\S]+)\\)$/, '
$1')" + ".replace(/^(.*) " + noteRegex + "$/, '$1 " + TO_BE_MINTED_NOTE + "')" + ".replace(/^([^<].*)$/, '$1')" + ";}");
}
/**
@@ -1123,6 +1126,44 @@ public static String getUriLabel(String uri) {
return uriLabel;
}
+ /**
+ * Whether a term typed into a choice field can be entered as a plain name for a resource that
+ * has no identifier yet (issue #652): it is not a URI already, and the IRI validator accepts
+ * it once a prefix is put in front of it -- the field's own, or the local one when it has
+ * none, in which case the nanopublication mints it under its own namespace.
+ *
+ * @param term the term typed into the field
+ * @return true if the term can be offered as a plain name
+ */
+ public static boolean isPlainName(String term) {
+ if (term == null || term.isBlank()) return false;
+ if (isUriValue(term)) return false;
+ // Same rule as the validator: no colon, hash or whitespace, and well-formed as a URI once
+ // prefixed.
+ if (!term.matches("[^:#\\s]+")) return false;
+ return isWellFormedUri(LocalUri.PREFIX + term);
+ }
+
+ /**
+ * How a value that a nanopublication will mint under its own namespace is shown in a choice
+ * field: with the local prefix in front of it and marked as not being an identifier yet, e.g.
+ * "local:john (to be minted)" (issue #652). See
+ * {@link com.knowledgepixels.nanodash.template.TemplateContext#isToBeMinted(IRI, String)} for
+ * which values these are.
+ *
+ * @param value the plain name held for the placeholder
+ * @return the label to show for it
+ */
+ public static String getToBeMintedLabel(String value) {
+ return LocalUri.PREFIX + value + " " + TO_BE_MINTED_NOTE;
+ }
+
+ /**
+ * The note appended to a to-be-minted value, set apart from the value itself by
+ * {@link #setSelect2ChoiceMinimalEscapeMarkup(Select2Choice)}.
+ */
+ private static final String TO_BE_MINTED_NOTE = "(mint locally)";
+
/**
* Gets an ExternalLink with a URI label.
*
@@ -1142,7 +1183,29 @@ public static ExternalLink getUriLink(String markupId, String uri) {
* @return an ExternalLink with the URI label
*/
public static ExternalLink getUriLink(String markupId, IModel model) {
- return new ExternalLink(markupId, model, new UriLabelModel(model));
+ return new ExternalLink(markupId, new UriHrefModel(model), new UriLabelModel(model));
+ }
+
+ /**
+ * The href of a URI link: empty for anything that isn't a URI to link to, so that a local
+ * URI or a locally minted name (issue #652) isn't turned into a relative link. This mirrors
+ * what {@link #getUriLink(String, String)} does with local URIs.
+ */
+ private static class UriHrefModel implements IModel {
+
+ private IModel uriModel;
+
+ public UriHrefModel(IModel uriModel) {
+ this.uriModel = uriModel;
+ }
+
+ @Override
+ public String getObject() {
+ String uri = uriModel.getObject();
+ if (uri == null || isLocalURI(uri) || !isUriValue(uri)) return "";
+ return uri;
+ }
+
}
private static class UriLabelModel implements IModel {
diff --git a/src/main/java/com/knowledgepixels/nanodash/component/AgentChoiceItem.java b/src/main/java/com/knowledgepixels/nanodash/component/AgentChoiceItem.java
index e03f2f0a..3d267fba 100644
--- a/src/main/java/com/knowledgepixels/nanodash/component/AgentChoiceItem.java
+++ b/src/main/java/com/knowledgepixels/nanodash/component/AgentChoiceItem.java
@@ -47,12 +47,31 @@ public class AgentChoiceItem extends AbstractContextComponent {
private static final Logger logger = LoggerFactory.getLogger(AgentChoiceItem.class);
private String getChoiceLabel(String choiceId) {
- IRI iri = vf.createIRI(choiceId);
+ IRI iri;
+ try {
+ iri = vf.createIRI(choiceId);
+ } catch (IllegalArgumentException ex) {
+ // A manually entered local name (issue #652) isn't a URI yet -- it is minted under
+ // the namespace of the nanopublication at publication time -- so there is no agent
+ // to look up, and the name itself is the best label we have.
+ return choiceId;
+ }
String name = User.getName(iri);
if (name != null) return name;
return choiceId;
}
+ /**
+ * Whether a manually entered term can be minted as a local identifier: a plain name that is
+ * not an ORCID, which is offered as the ORCID URI instead (issue #652).
+ *
+ * @param term the term typed into the field
+ * @return true if the term can be offered as a locally minted identifier
+ */
+ private static boolean isLocalName(String term) {
+ return Utils.isPlainName(term) && !term.matches(ProfilePage.ORCID_PATTERN);
+ }
+
/**
* Constructor for AgentChoiceItem.
*
@@ -87,8 +106,14 @@ public AgentChoiceItem(String id, String parentId, final IRI iriP, boolean optio
@Override
public String getDisplayValue(String choiceId) {
if (choiceId == null || choiceId.isEmpty()) return "";
+ // A manually entered name is not an identifier yet, so it is shown as the local
+ // URI it will be minted into rather than as a bare word (issue #652).
+ if (context.isToBeMinted(iri, choiceId)) return Utils.getToBeMintedLabel(choiceId);
String label = getChoiceLabel(choiceId);
- if (label == null || label.isBlank()) {
+ // No name to show for an agent that isn't a known user -- a manually entered URI
+ // (issue #652) -- and repeating the value as its own label would only render it
+ // twice.
+ if (label == null || label.isBlank() || label.equals(choiceId)) {
return choiceId;
}
return label + " (" + choiceId + ")";
@@ -116,7 +141,10 @@ public void query(String term, int page, Response response) {
}
return;
}
- if (term.startsWith("https://") || term.startsWith("http://")) {
+ // Any URI in an allowed scheme can be entered manually, not just http(s) ones
+ // (issue #652).
+ final String typedTerm = term;
+ if (Utils.isUriValue(term)) {
response.add(term);
} else if (term.matches(ProfilePage.ORCID_PATTERN)) {
response.add("https://orcid.org/" + term);
@@ -153,6 +181,14 @@ public void query(String term, int page, Response response) {
response.add(iri.stringValue());
}
}
+
+ // Anything else the validator accepts is offered as a locally minted identifier
+ // (issue #652): typing "john-doe" mints it under the namespace of the
+ // nanopublication being published. It comes last, so that the known users the
+ // term matches keep the top of the list.
+ if (isLocalName(typedTerm) && !response.getResults().contains(typedTerm)) {
+ response.add(typedTerm);
+ }
}
@Override
@@ -165,7 +201,7 @@ public Collection toChoices(Collection ids) {
textfield.getSettings().getAjax(true).setDelay(500);
textfield.getSettings().setCloseOnSelect(true);
String placeholder = template.getLabel(iri);
- if (placeholder == null) placeholder = "select user or paste ORCID/URL";
+ if (placeholder == null) placeholder = "select user or type name/ORCID/URI";
textfield.getSettings().setPlaceholder(placeholder);
Utils.setSelect2ChoiceMinimalEscapeMarkup(textfield);
textfield.getSettings().setAllowClear(true);
@@ -204,7 +240,9 @@ protected void onComponentTag(ComponentTag tag) {
try {
selectedIri = vf.createIRI(selectedValue);
} catch (IllegalArgumentException e) {
- selectedIri = NanodashSession.get().getUserIri();
+ // A locally minted name (issue #652) identifies somebody other than the
+ // logged-in user, so it gets the generic icon rather than their picture.
+ selectedIri = null;
}
} else {
selectedIri = NanodashSession.get().getUserIri();
diff --git a/src/main/java/com/knowledgepixels/nanodash/component/GuidedChoiceItem.java b/src/main/java/com/knowledgepixels/nanodash/component/GuidedChoiceItem.java
index 823d0a45..9cbb378e 100644
--- a/src/main/java/com/knowledgepixels/nanodash/component/GuidedChoiceItem.java
+++ b/src/main/java/com/knowledgepixels/nanodash/component/GuidedChoiceItem.java
@@ -144,6 +144,9 @@ public GuidedChoiceItem(String id, String parentId, final IRI iriP, boolean opti
@Override
public String getDisplayValue(String choiceId) {
if (choiceId == null || choiceId.isEmpty()) return "";
+ // A value that has no identifier yet is shown as the local URI it will be minted
+ // into rather than as a bare word (issue #652).
+ if (context.isToBeMinted(iri, choiceId)) return Utils.getToBeMintedLabel(choiceId);
String label = getChoiceLabel(choiceId);
if (label == null || label.isBlank()) {
return choiceId;
@@ -167,6 +170,7 @@ public void query(String term, int page, Response response) {
response.addAll(possibleValues);
return;
}
+ final String typedTerm = term;
if (Utils.isUriValue(term)) {
if (prefix == null || term.startsWith(prefix)) {
response.add(term);
@@ -183,6 +187,15 @@ public void query(String term, int page, Response response) {
for (String v : context.getTemplate().getPossibleValuesFromApi(iri, term, labelMap)) {
if (!alreadyAddedMap.containsKey(v)) response.add(v);
}
+
+ // A guided choice only suggests values, it doesn't limit them, so a plain name for
+ // a resource that has no identifier yet can be entered as well (issue #652): it is
+ // minted under the prefix of the field, or under the namespace of the
+ // nanopublication when the field has none. It comes last, so that the suggestions
+ // the term matches keep the top of the list.
+ if (Utils.isPlainName(typedTerm) && !response.getResults().contains(typedTerm)) {
+ response.add(typedTerm);
+ }
}
@Override
diff --git a/src/main/java/com/knowledgepixels/nanodash/template/TemplateContext.java b/src/main/java/com/knowledgepixels/nanodash/template/TemplateContext.java
index d028da65..d1c8090b 100644
--- a/src/main/java/com/knowledgepixels/nanodash/template/TemplateContext.java
+++ b/src/main/java/com/knowledgepixels/nanodash/template/TemplateContext.java
@@ -370,6 +370,29 @@ public boolean hasUnresolvedPrefix(IRI iri) {
return token != null && resolvePrefixBase(token) == null;
}
+ /**
+ * Whether the given value, held for the given placeholder, will be minted as a new IRI under
+ * the namespace of the nanopublication being published, rather than referring to an existing
+ * one (issue #652). This mirrors what {@link #processValue(Value)} does with a plain name that
+ * has no prefix in front of it, and lets the form show such a value as what it is rather than
+ * as a bare word.
+ *
+ * @param iri the placeholder IRI
+ * @param value the value currently held for that placeholder
+ * @return true if publishing would mint the value under the target namespace
+ */
+ public boolean isToBeMinted(IRI iri, String value) {
+ // The same rule processValue applies: a plain name (no colon, hash or space) gets the
+ // target namespace put in front of it. The colon also rules out anything that is a URI.
+ if (value == null || !value.matches("[^:# ]+")) return false;
+ // A space-/namespace-dependent prefix mints the resource under the space or maintained
+ // resource instead, so it is not a local identifier of this nanopublication.
+ if (hasDynamicPrefix(iri)) return false;
+ if (template.isLocalResource(iri)) return true;
+ String prefix = getPrefix(iri);
+ return prefix == null || prefix.isEmpty();
+ }
+
private String resolvePrefixBase(String token) {
String base = DynamicPrefix.resolveFromContext(token, navigationContextId);
if (base != null && !base.isEmpty()) return base;
diff --git a/src/main/webapp/style.css b/src/main/webapp/style.css
index 0f183866..f076b128 100644
--- a/src/main/webapp/style.css
+++ b/src/main/webapp/style.css
@@ -993,7 +993,8 @@ a.actionlink {
max-height: 28px;
}
-.select2-selection__rendered > span.term {
+.select2-selection__rendered > span.term,
+.select2-selection__rendered > span.mint-note {
display: inline-block;
margin-top: 3px;
}
@@ -1002,6 +1003,15 @@ a.actionlink {
font-weight: bold;
}
+/* The note on a value that the nanopublication will mint itself: an aside about the value, not
+ part of it, so it is set apart from the term next to it -- but only in weight and slant, since
+ it sits on the same line and shares the rules above with that term (issue #652). */
+span.mint-note {
+ font-weight: normal;
+ font-style: italic;
+ vertical-align: middle;
+}
+
.select2-dropdown.select2-dropdown--below {
min-width: 600px !important;
margin-top: -1px;
diff --git a/src/test/java/com/knowledgepixels/nanodash/component/AgentChoiceItemTest.java b/src/test/java/com/knowledgepixels/nanodash/component/AgentChoiceItemTest.java
new file mode 100644
index 00000000..7597f11b
--- /dev/null
+++ b/src/test/java/com/knowledgepixels/nanodash/component/AgentChoiceItemTest.java
@@ -0,0 +1,188 @@
+package com.knowledgepixels.nanodash.component;
+
+import com.knowledgepixels.nanodash.WicketApplication;
+import com.knowledgepixels.nanodash.domain.User;
+import com.knowledgepixels.nanodash.domain.UserData;
+import com.knowledgepixels.nanodash.template.ContextType;
+import com.knowledgepixels.nanodash.template.Template;
+import com.knowledgepixels.nanodash.template.TemplateContext;
+import com.knowledgepixels.nanodash.template.TemplateData;
+import com.knowledgepixels.nanodash.template.TemplateTestUtil;
+import org.apache.wicket.Component;
+import org.apache.wicket.util.tester.WicketTester;
+import org.eclipse.rdf4j.model.IRI;
+import org.eclipse.rdf4j.model.ValueFactory;
+import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
+import org.eclipse.rdf4j.model.vocabulary.RDF;
+import org.eclipse.rdf4j.model.vocabulary.RDFS;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.nanopub.NanopubCreator;
+import org.nanopub.vocabulary.NTEMPLATE;
+import org.wicketstuff.select2.Response;
+import org.wicketstuff.select2.Select2Choice;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+/**
+ * Component-level tests for what an agent placeholder accepts: known users are suggested, but
+ * any URI in an allowed scheme and any locally minted name can also be entered by hand
+ * (issue #652).
+ */
+public class AgentChoiceItemTest {
+
+ private static final ValueFactory vf = SimpleValueFactory.getInstance();
+
+ private static final String NP_URI = "https://w3id.org/np/RAAbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_AbCdE";
+ private static final IRI AGENT = vf.createIRI(NP_URI + "/agent");
+
+ private WicketTester tester;
+ private MockedStatic templateDataMockedStatic;
+ private MockedStatic userMockedStatic;
+
+ @BeforeEach
+ void setUp() {
+ tester = new WicketTester(new WicketApplication());
+ templateDataMockedStatic = mockStatic(TemplateData.class);
+ // The known users are looked up on every keystroke; keep the test off the network.
+ userMockedStatic = mockStatic(User.class, CALLS_REAL_METHODS);
+ userMockedStatic.when(User::getUserData).thenReturn(mock(UserData.class));
+ }
+
+ @AfterEach
+ void tearDown() {
+ userMockedStatic.close();
+ templateDataMockedStatic.close();
+ }
+
+ /**
+ * Builds a one-statement template whose object is an agent placeholder, and returns an
+ * initialized context for it.
+ */
+ private TemplateContext agentContext() throws Exception {
+ NanopubCreator creator = new NanopubCreator(NP_URI);
+ creator.addProvenanceStatement(vf.createStatement(creator.getAssertionUri(), RDFS.SEEALSO, creator.getAssertionUri()));
+ creator.addPubinfoStatement(vf.createStatement(creator.getNanopubUri(), RDFS.SEEALSO, creator.getNanopubUri()));
+ IRI templateNode = creator.getAssertionUri();
+ IRI st1 = vf.createIRI(NP_URI + "/st1");
+ creator.addAssertionStatement(templateNode, RDF.TYPE, NTEMPLATE.ASSERTION_TEMPLATE);
+ creator.addAssertionStatement(templateNode, RDFS.LABEL, vf.createLiteral("Agent component test template"));
+ creator.addAssertionStatement(templateNode, NTEMPLATE.HAS_STATEMENT, st1);
+ creator.addAssertionStatement(st1, RDF.SUBJECT, vf.createIRI("http://example.com/subject"));
+ creator.addAssertionStatement(st1, RDF.PREDICATE, vf.createIRI("http://example.com/hasAgent"));
+ creator.addAssertionStatement(st1, RDF.OBJECT, AGENT);
+ creator.addAssertionStatement(AGENT, RDF.TYPE, NTEMPLATE.AGENT_PLACEHOLDER);
+ creator.addAssertionStatement(AGENT, RDFS.LABEL, vf.createLiteral("agent"));
+ Template template = TemplateTestUtil.parseTemplate(creator.finalizeNanopub());
+
+ TemplateData templateDataMock = mock(TemplateData.class);
+ templateDataMockedStatic.when(TemplateData::get).thenReturn(templateDataMock);
+ when(templateDataMock.getTemplate(NP_URI)).thenReturn(template);
+
+ TemplateContext context = new TemplateContext(ContextType.ASSERTION, NP_URI, "statement", (String) null);
+ context.initStatements();
+ return context;
+ }
+
+ @SuppressWarnings("unchecked")
+ private List suggestionsFor(TemplateContext context, String term) {
+ Select2Choice field = null;
+ for (Component c : context.getComponents()) {
+ if (c instanceof Select2Choice) field = (Select2Choice) c;
+ }
+ assertTrue(field != null, "the agent placeholder must render a Select2 choice");
+ Response response = new Response<>();
+ field.getProvider().query(term, 0, response);
+ return response.getResults();
+ }
+
+ @SuppressWarnings("unchecked")
+ private String displayValueFor(TemplateContext context, String choiceId) {
+ for (Component c : context.getComponents()) {
+ if (c instanceof Select2Choice) return ((Select2Choice) c).getProvider().getDisplayValue(choiceId);
+ }
+ throw new AssertionError("the agent placeholder must render a Select2 choice");
+ }
+
+ /**
+ * A name that has no identifier yet is shown as the local URI it will be minted into, so that
+ * it doesn't read like an existing agent.
+ */
+ @Test
+ void toBeMintedNameIsShownWithTheLocalPrefix() throws Exception {
+ assertEquals("local:john-doe (mint locally)", displayValueFor(agentContext(), "john-doe"));
+ }
+
+ /**
+ * A URI of an agent that is not a known user has no name to show, so it must be rendered once
+ * rather than as "value (value)".
+ */
+ @Test
+ void uriWithoutAKnownNameIsShownOnce() throws Exception {
+ TemplateContext context = agentContext();
+ assertEquals("https://example.com/agents/jd", displayValueFor(context, "https://example.com/agents/jd"));
+ assertEquals("did:plc:z72i7hdynmk6r22z27h6tvur", displayValueFor(context, "did:plc:z72i7hdynmk6r22z27h6tvur"));
+ }
+
+ @Test
+ void httpUriIsOffered() throws Exception {
+ assertTrue(suggestionsFor(agentContext(), "https://example.com/agents/jd").contains("https://example.com/agents/jd"));
+ }
+
+ @Test
+ void orcidIsOfferedAsUri() throws Exception {
+ List suggestions = suggestionsFor(agentContext(), "0000-0002-1267-0234");
+ assertTrue(suggestions.contains("https://orcid.org/0000-0002-1267-0234"));
+ // The bare ORCID itself would be a valid local name, but offering it next to the ORCID
+ // URI would only be a confusing near-duplicate.
+ assertFalse(suggestions.contains("0000-0002-1267-0234"));
+ }
+
+ @Test
+ void uriInAnyAllowedSchemeIsOffered() throws Exception {
+ assertTrue(suggestionsFor(agentContext(), "did:plc:z72i7hdynmk6r22z27h6tvur").contains("did:plc:z72i7hdynmk6r22z27h6tvur"));
+ }
+
+ @Test
+ void plainNameIsOfferedAsLocallyMintedIdentifier() throws Exception {
+ assertTrue(suggestionsFor(agentContext(), "john-doe").contains("john-doe"));
+ }
+
+ @Test
+ void termWithWhitespaceIsNotOffered() throws Exception {
+ // A name with a space cannot be minted into a well-formed IRI, so the validator would
+ // reject it; it must not be offered as a choice in the first place.
+ assertTrue(suggestionsFor(agentContext(), "john doe").isEmpty());
+ }
+
+ /**
+ * The counterpart of the suggestion: a locally minted name ends up as an IRI under the
+ * namespace of the nanopublication being published.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ void plainNameIsMintedUnderTheTargetNamespace() throws Exception {
+ TemplateContext context = agentContext();
+ ((org.apache.wicket.model.IModel) context.getComponentModels().get(AGENT)).setObject("john-doe");
+ assertEquals(vf.createIRI(Template.DEFAULT_TARGET_NAMESPACE + "john-doe"), context.processIri(AGENT));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void enteredUriIsKeptAsIs() throws Exception {
+ TemplateContext context = agentContext();
+ ((org.apache.wicket.model.IModel) context.getComponentModels().get(AGENT)).setObject("did:plc:z72i7hdynmk6r22z27h6tvur");
+ assertEquals(vf.createIRI("did:plc:z72i7hdynmk6r22z27h6tvur"), context.processIri(AGENT));
+ }
+
+}
diff --git a/src/test/java/com/knowledgepixels/nanodash/component/GuidedChoiceItemTest.java b/src/test/java/com/knowledgepixels/nanodash/component/GuidedChoiceItemTest.java
new file mode 100644
index 00000000..8509d091
--- /dev/null
+++ b/src/test/java/com/knowledgepixels/nanodash/component/GuidedChoiceItemTest.java
@@ -0,0 +1,182 @@
+package com.knowledgepixels.nanodash.component;
+
+import com.knowledgepixels.nanodash.WicketApplication;
+import com.knowledgepixels.nanodash.domain.User;
+import com.knowledgepixels.nanodash.domain.UserData;
+import com.knowledgepixels.nanodash.template.ContextType;
+import com.knowledgepixels.nanodash.template.Template;
+import com.knowledgepixels.nanodash.template.TemplateContext;
+import com.knowledgepixels.nanodash.template.TemplateData;
+import com.knowledgepixels.nanodash.template.TemplateTestUtil;
+import org.apache.wicket.Component;
+import org.apache.wicket.util.tester.WicketTester;
+import org.eclipse.rdf4j.model.IRI;
+import org.eclipse.rdf4j.model.ValueFactory;
+import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
+import org.eclipse.rdf4j.model.vocabulary.RDF;
+import org.eclipse.rdf4j.model.vocabulary.RDFS;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.nanopub.NanopubCreator;
+import org.nanopub.vocabulary.NTEMPLATE;
+import org.wicketstuff.select2.Response;
+import org.wicketstuff.select2.Select2Choice;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+/**
+ * Component-level tests for what a guided choice placeholder accepts: it suggests values but does
+ * not limit them, so a plain name for a resource that has no identifier yet can be entered too
+ * (issue #652).
+ */
+public class GuidedChoiceItemTest {
+
+ private static final ValueFactory vf = SimpleValueFactory.getInstance();
+
+ private static final String NP_URI = "https://w3id.org/np/RAAbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_AbCdE";
+ private static final IRI THING = vf.createIRI(NP_URI + "/thing");
+
+ private MockedStatic templateDataMockedStatic;
+ private MockedStatic userMockedStatic;
+
+ @BeforeEach
+ void setUp() {
+ new WicketTester(new WicketApplication());
+ templateDataMockedStatic = mockStatic(TemplateData.class);
+ // Building the form looks users up; keep the test off the network.
+ userMockedStatic = mockStatic(User.class, CALLS_REAL_METHODS);
+ userMockedStatic.when(User::getUserData).thenReturn(mock(UserData.class));
+ }
+
+ @AfterEach
+ void tearDown() {
+ userMockedStatic.close();
+ templateDataMockedStatic.close();
+ }
+
+ /**
+ * Builds a one-statement template whose object is a guided choice placeholder with the given
+ * prefix (none if null), and returns an initialized context for it.
+ */
+ private TemplateContext guidedContext(String prefix) throws Exception {
+ NanopubCreator creator = new NanopubCreator(NP_URI);
+ creator.addProvenanceStatement(vf.createStatement(creator.getAssertionUri(), RDFS.SEEALSO, creator.getAssertionUri()));
+ creator.addPubinfoStatement(vf.createStatement(creator.getNanopubUri(), RDFS.SEEALSO, creator.getNanopubUri()));
+ IRI templateNode = creator.getAssertionUri();
+ IRI st1 = vf.createIRI(NP_URI + "/st1");
+ creator.addAssertionStatement(templateNode, RDF.TYPE, NTEMPLATE.ASSERTION_TEMPLATE);
+ creator.addAssertionStatement(templateNode, RDFS.LABEL, vf.createLiteral("Guided choice component test template"));
+ creator.addAssertionStatement(templateNode, NTEMPLATE.HAS_STATEMENT, st1);
+ creator.addAssertionStatement(st1, RDF.SUBJECT, vf.createIRI("http://example.com/subject"));
+ creator.addAssertionStatement(st1, RDF.PREDICATE, vf.createIRI("http://example.com/hasThing"));
+ creator.addAssertionStatement(st1, RDF.OBJECT, THING);
+ creator.addAssertionStatement(THING, RDF.TYPE, NTEMPLATE.GUIDED_CHOICE_PLACEHOLDER);
+ creator.addAssertionStatement(THING, RDFS.LABEL, vf.createLiteral("thing"));
+ if (prefix != null) {
+ creator.addAssertionStatement(THING, NTEMPLATE.HAS_PREFIX, vf.createLiteral(prefix));
+ }
+ Template template = TemplateTestUtil.parseTemplate(creator.finalizeNanopub());
+
+ TemplateData templateDataMock = mock(TemplateData.class);
+ templateDataMockedStatic.when(TemplateData::get).thenReturn(templateDataMock);
+ when(templateDataMock.getTemplate(NP_URI)).thenReturn(template);
+
+ TemplateContext context = new TemplateContext(ContextType.ASSERTION, NP_URI, "statement", (String) null);
+ context.initStatements();
+ return context;
+ }
+
+ @SuppressWarnings("unchecked")
+ private Select2Choice fieldOf(TemplateContext context) {
+ for (Component c : context.getComponents()) {
+ if (c instanceof Select2Choice) return (Select2Choice) c;
+ }
+ throw new AssertionError("the guided choice placeholder must render a Select2 choice");
+ }
+
+ private List suggestionsFor(TemplateContext context, String term) {
+ Response response = new Response<>();
+ fieldOf(context).getProvider().query(term, 0, response);
+ return response.getResults();
+ }
+
+ @Test
+ void plainNameIsOffered() throws Exception {
+ assertTrue(suggestionsFor(guidedContext(null), "john").contains("john"));
+ }
+
+ @Test
+ void uriIsOffered() throws Exception {
+ assertTrue(suggestionsFor(guidedContext(null), "https://example.com/thing").contains("https://example.com/thing"));
+ }
+
+ @Test
+ void termWithWhitespaceIsNotOffered() throws Exception {
+ // A name with a space cannot be turned into a well-formed IRI, so the validator would
+ // reject it; it must not be offered as a choice in the first place.
+ assertTrue(suggestionsFor(guidedContext(null), "john doe").isEmpty());
+ }
+
+ /**
+ * Without a prefix, the nanopublication mints the name under its own namespace, and the field
+ * says so.
+ */
+ @Test
+ void plainNameIsShownAsMintedLocally() throws Exception {
+ assertEquals("local:john (mint locally)", fieldOf(guidedContext(null)).getProvider().getDisplayValue("john"));
+ }
+
+ /**
+ * With a prefix, the name is minted under that prefix instead, which the field shows next to
+ * the value -- so it is not a local identifier and must not be marked as one.
+ */
+ @Test
+ void plainNameUnderAPrefixIsNotMintedLocally() throws Exception {
+ TemplateContext context = guidedContext("https://example.org/");
+ assertTrue(suggestionsFor(context, "john").contains("john"));
+ assertEquals("john", fieldOf(context).getProvider().getDisplayValue("john"));
+ }
+
+ /**
+ * A restricted choice is restricted: it must keep offering only what the template allows.
+ */
+ @Test
+ void restrictedChoiceOffersNoPlainName() throws Exception {
+ NanopubCreator creator = new NanopubCreator(NP_URI);
+ creator.addProvenanceStatement(vf.createStatement(creator.getAssertionUri(), RDFS.SEEALSO, creator.getAssertionUri()));
+ creator.addPubinfoStatement(vf.createStatement(creator.getNanopubUri(), RDFS.SEEALSO, creator.getNanopubUri()));
+ IRI templateNode = creator.getAssertionUri();
+ IRI st1 = vf.createIRI(NP_URI + "/st1");
+ creator.addAssertionStatement(templateNode, RDF.TYPE, NTEMPLATE.ASSERTION_TEMPLATE);
+ creator.addAssertionStatement(templateNode, RDFS.LABEL, vf.createLiteral("Restricted choice component test template"));
+ creator.addAssertionStatement(templateNode, NTEMPLATE.HAS_STATEMENT, st1);
+ creator.addAssertionStatement(st1, RDF.SUBJECT, vf.createIRI("http://example.com/subject"));
+ creator.addAssertionStatement(st1, RDF.PREDICATE, vf.createIRI("http://example.com/hasThing"));
+ creator.addAssertionStatement(st1, RDF.OBJECT, THING);
+ creator.addAssertionStatement(THING, RDF.TYPE, NTEMPLATE.RESTRICTED_CHOICE_PLACEHOLDER);
+ creator.addAssertionStatement(THING, RDFS.LABEL, vf.createLiteral("thing"));
+ creator.addAssertionStatement(THING, NTEMPLATE.POSSIBLE_VALUE, vf.createIRI("https://example.com/allowed-thing"));
+ Template template = TemplateTestUtil.parseTemplate(creator.finalizeNanopub());
+ TemplateData templateDataMock = mock(TemplateData.class);
+ templateDataMockedStatic.when(TemplateData::get).thenReturn(templateDataMock);
+ when(templateDataMock.getTemplate(NP_URI)).thenReturn(template);
+ TemplateContext context = new TemplateContext(ContextType.ASSERTION, NP_URI, "statement", (String) null);
+ context.initStatements();
+
+ List suggestions = suggestionsFor(context, "john");
+ assertFalse(suggestions.contains("john"), "a restricted choice must not offer a made-up name");
+ assertTrue(suggestions.isEmpty());
+ assertEquals("john", fieldOf(context).getProvider().getDisplayValue("john"));
+ }
+
+}