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")); + } + +}