/* * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You 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 org.apache.wicket.util.tester; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import org.apache.wicket.Component; import org.apache.wicket.WicketRuntimeException; import org.apache.wicket.ajax.markup.html.form.AjaxButton; import org.apache.wicket.ajax.markup.html.form.AjaxSubmitLink; import org.apache.wicket.markup.html.form.AbstractSingleSelectChoice; import org.apache.wicket.markup.html.form.AbstractTextComponent; import org.apache.wicket.markup.html.form.Check; import org.apache.wicket.markup.html.form.CheckGroup; import org.apache.wicket.markup.html.form.Form; import org.apache.wicket.markup.html.form.FormComponent; import org.apache.wicket.markup.html.form.IChoiceRenderer; import org.apache.wicket.markup.html.form.IFormSubmittingComponent; import org.apache.wicket.markup.html.form.ListMultipleChoice; import org.apache.wicket.markup.html.form.Radio; import org.apache.wicket.markup.html.form.RadioGroup; import org.apache.wicket.markup.html.form.FormComponentUpdatingBehavior; import org.apache.wicket.markup.html.form.upload.FileUploadField; import org.apache.wicket.markup.html.form.upload.MultiFileUploadField; import org.apache.wicket.protocol.http.mock.MockHttpServletRequest; import org.apache.wicket.util.file.File; import org.apache.wicket.util.lang.Args; import org.apache.wicket.util.string.StringValue; import org.apache.wicket.util.string.Strings; import org.apache.wicket.util.visit.IVisit; import org.apache.wicket.util.visit.IVisitor; import org.junit.Assert; /** * A helper class for testing validation and submission of <code>FormComponent</code>s. * * @author Ingram Chen * @author Frank Bille (frankbille) * @since 1.2.6 */ public class FormTester { /** * An auto incrementing index used as a suffix for MultiFileUploadField's inputName */ private int multiFileUploadIndex = 0; /** * A selector template for selecting selectable <code>FormComponent</code>s with an index of * option -- supports <code>RadioGroup</code>, <code>CheckGroup</code>, and * <code>AbstractChoice</code> family. */ protected abstract class ChoiceSelector { /** * TODO need Javadoc from author. */ private final class SearchOptionByIndexVisitor implements IVisitor<Component, Component> { int count = 0; private final int index; private SearchOptionByIndexVisitor(int index) { super(); this.index = index; } /** * @see org.apache.wicket.util.visit.IVisitor#component(Object, * org.apache.wicket.util.visit.IVisit) */ @Override public void component(final Component component, final IVisit<Component> visit) { if (count == index) { visit.stop(component); } else { count++; } } } private final FormComponent<?> formComponent; /** * Constructor. * * @param formComponent * a <code>FormComponent</code> */ protected ChoiceSelector(FormComponent<?> formComponent) { this.formComponent = formComponent; } /** * Implements whether toggle or accumulate the selection. * * @param formComponent * a <code>FormComponent</code> * @param value * a <code>String</code> value */ protected abstract void assignValueToFormComponent(FormComponent<?> formComponent, String value); public String getChoiceValueForIndex(int index) { if (formComponent instanceof RadioGroup) { Radio<?> foundRadio = (Radio<?>)formComponent.visitChildren(Radio.class, new SearchOptionByIndexVisitor(index)); if (foundRadio == null) { fail("RadioGroup " + formComponent.getPath() + " does not have index:" + index); return null; } return foundRadio.getValue(); } else if (formComponent instanceof CheckGroup) { Check<?> foundCheck = (Check<?>)formComponent.visitChildren(Check.class, new SearchOptionByIndexVisitor(index)); if (foundCheck == null) { fail("CheckGroup " + formComponent.getPath() + " does not have index:" + index); return null; } return foundCheck.getValue(); } else { String idValue = selectAbstractChoice(formComponent, index); if (idValue == null) { fail(formComponent.getPath() + " is not a selectable Component."); return null; } else { return idValue; } } } /** * Selects a given index in a selectable <code>FormComponent</code>. * * @param index */ protected final void doSelect(final int index) { String value = getChoiceValueForIndex(index); assignValueToFormComponent(formComponent, value); } /** * Selects a given index in a selectable <code>FormComponent</code>. * * @param formComponent * a <code>FormComponent</code> * @param index * the index to select * @return the id value at the selected index */ @SuppressWarnings("unchecked") private String selectAbstractChoice(final FormComponent<?> formComponent, final int index) { try { Method getChoicesMethod = formComponent.getClass().getMethod("getChoices", (Class<?>[])null); getChoicesMethod.setAccessible(true); List<Object> choices = (List<Object>)getChoicesMethod.invoke(formComponent, (Object[])null); Method getChoiceRendererMethod = formComponent.getClass().getMethod( "getChoiceRenderer", (Class<?>[])null); getChoiceRendererMethod.setAccessible(true); IChoiceRenderer<Object> choiceRenderer = (IChoiceRenderer<Object>)getChoiceRendererMethod.invoke( formComponent, (Object[])null); return choiceRenderer.getIdValue(choices.get(index), index); } catch (SecurityException e) { throw new WicketRuntimeException("unexpect select failure", e); } catch (NoSuchMethodException e) { // component without getChoices() or getChoiceRenderer() is not // selectable return null; } catch (IllegalAccessException e) { throw new WicketRuntimeException("unexpect select failure", e); } catch (InvocationTargetException e) { throw new WicketRuntimeException("unexpect select failure", e); } } } /** * A factory that creates an appropriate <code>ChoiceSelector</code> based on type of * <code>FormComponent</code>. */ private class ChoiceSelectorFactory { /** * <code>MultipleChoiceSelector</code> class. */ private final class MultipleChoiceSelector extends ChoiceSelector { /** * Constructor. * * @param formComponent * a <code>FormComponent</code> */ protected MultipleChoiceSelector(FormComponent<?> formComponent) { super(formComponent); if (!allowMultipleChoice(formComponent)) { fail("Component:'" + formComponent.getPath() + "' Does not support multiple selection."); } } @Override protected void assignValueToFormComponent(FormComponent<?> formComponent, String value) { // multiple selectable should retain selected option addFormComponentValue(formComponent, value); } } /** * <code>SingleChoiceSelector</code> class. */ private final class SingleChoiceSelector extends ChoiceSelector { /** * Constructor. * * @param formComponent * a <code>FormComponent</code> */ protected SingleChoiceSelector(FormComponent<?> formComponent) { super(formComponent); } @Override protected void assignValueToFormComponent(FormComponent<?> formComponent, String value) { // single selectable should overwrite already selected option setFormComponentValue(formComponent, value); } } /** * Creates a <code>ChoiceSelector</code>. * * @param formComponent * a <code>FormComponent</code> * @return ChoiceSelector a <code>ChoiceSelector</code> */ protected ChoiceSelector create(FormComponent<?> formComponent) { if (formComponent == null) { fail("Trying to select on null component."); return null; } else if (formComponent instanceof RadioGroup || formComponent instanceof AbstractSingleSelectChoice) { return new SingleChoiceSelector(formComponent); } else if (allowMultipleChoice(formComponent)) { return new MultipleChoiceSelector(formComponent); } else { fail("Selecting on the component:'" + formComponent.getPath() + "' is not supported."); return null; } } /** * Creates a <code>MultipleChoiceSelector</code>. * * @param formComponent * a <code>FormComponent</code> * @return ChoiceSelector a <code>ChoiceSelector</code> */ protected ChoiceSelector createForMultiple(FormComponent<?> formComponent) { return new MultipleChoiceSelector(formComponent); } /** * Tests if a given <code>FormComponent</code> allows multiple choice. * * @param formComponent * a <code>FormComponent</code> * @return <code>true</code> if the given FormComponent allows multiple choice */ private boolean allowMultipleChoice(FormComponent<?> formComponent) { return formComponent instanceof CheckGroup || formComponent instanceof ListMultipleChoice; } } private final ChoiceSelectorFactory choiceSelectorFactory = new ChoiceSelectorFactory(); /** * An instance of <code>FormTester</code> can only be used once. Create a new instance of each * test. */ private boolean closed = false; /** path to <code>FormComponent</code> */ private final String path; /** <code>BaseWicketTester</code> that create <code>FormTester</code> */ private final BaseWicketTester tester; /** <code>FormComponent</code> to be tested */ private final Form<?> workingForm; private boolean clearFeedbackMessagesBeforeSubmit = true; /** * @see WicketTester#newFormTester(String) * * @param path * path to <code>FormComponent</code> * @param workingForm * <code>FormComponent</code> to be tested * @param wicketTester * <code>WicketTester</code> that creates <code>FormTester</code> * @param fillBlankString * specifies whether to fill child <code>TextComponent</code>s with blank * <code>String</code>s */ protected FormTester(final String path, final Form<?> workingForm, final BaseWicketTester wicketTester, final boolean fillBlankString) { this.path = path; this.workingForm = workingForm; tester = wicketTester; // fill blank String for Text Component. workingForm.visitFormComponents(new IVisitor<FormComponent<?>, Void>() { @Override public void component(final FormComponent<?> formComponent, final IVisit<Void> visit) { // do nothing for invisible or disabled component -- the browser would not send any // parameter for a disabled component if (!(formComponent.isVisibleInHierarchy() && formComponent.isEnabledInHierarchy())) { return; } String[] values = getInputValue(formComponent); if (formComponent instanceof AbstractTextComponent<?>) { if (values.length == 0 && fillBlankString) { setFormComponentValue(formComponent, ""); } } for (String value : values) { addFormComponentValue(formComponent, value); } } }); workingForm.detach(); } /** * Gets request parameter values for the form component that represents its current model value * * @param formComponent * @return array containing parameter values */ public static String[] getInputValue(FormComponent<?> formComponent) { // the browser sends parameters for visible and enabled components only if (formComponent.isVisibleInHierarchy() && formComponent.isEnabledInHierarchy()) { if (formComponent instanceof IFormSubmittingComponent) { // buttons have to be submitted explicitely } else if (formComponent instanceof AbstractTextComponent) { return new String[] { getFormComponentValue(formComponent) }; } else { // TODO is it safe to assume that all other components' values can be split? String value = getFormComponentValue(formComponent); if (!Strings.isEmpty(value)) { return value.split(FormComponent.VALUE_SEPARATOR); } } } return new String[] { }; } private static String getFormComponentValue(final FormComponent<?> formComponent) { boolean oldEscape = formComponent.getEscapeModelStrings(); formComponent.setEscapeModelStrings(false); String val = formComponent.getValue(); formComponent.setEscapeModelStrings(oldEscape); return val; } /** * Retrieves the current <code>Form</code> object. * * @return the working <code>Form</code> */ public Form<?> getForm() { return workingForm; } /** * Gets the value for an <code>AbstractTextComponent</code> with the provided id. * * @param id * <code>Component</code> id * @return the value of the text component */ public String getTextComponentValue(final String id) { Component c = getForm().get(id); if (c instanceof AbstractTextComponent) { return ((AbstractTextComponent<?>)c).getValue(); } return null; } /** * Simulates selecting an option of a <code>FormComponent</code>. Supports * <code>RadioGroup</code>, <code>CheckGroup</code>, and <code>AbstractChoice</code> family * currently. The behavior is similar to interacting on the browser: For a single choice, such * as <code>Radio</code> or <code>DropDownList</code>, the selection will toggle each other. For * multiple choice, such as <code>Checkbox</code> or <code>ListMultipleChoice</code>, the * selection will accumulate. * * @param formComponentId * relative path (from <code>Form</code>) to the selectable * <code>FormComponent</code> * @param index * index of the selectable option, starting from 0 * @return This */ public FormTester select(final String formComponentId, int index) { checkClosed(); FormComponent<?> component = (FormComponent<?>)workingForm.get(formComponentId); ChoiceSelector choiceSelector = choiceSelectorFactory.create(component); choiceSelector.doSelect(index); for (FormComponentUpdatingBehavior updater : component.getBehaviors(FormComponentUpdatingBehavior.class)) { tester.invokeListener(component, updater); } return this; } /** * A convenience method to select multiple options for the <code>FormComponent</code>. The * method only support multiple selectable <code>FormComponent</code>s. * * @see #select(String, int) * * @param formComponentId * relative path (from <code>Form</code>) to the selectable * <code>FormComponent</code> * @param indexes * index of the selectable option, starting from 0 * @return This */ public FormTester selectMultiple(String formComponentId, int[] indexes) { return selectMultiple(formComponentId, indexes, false); } /** * A convenience method to select multiple options for the <code>FormComponent</code>. The * method only support multiple selectable <code>FormComponent</code>s. * * @see #select(String, int) * * @param formComponentId * relative path (from <code>Form</code>) to the selectable * <code>FormComponent</code> * @param indexes * index of the selectable option, starting from 0 * @param replace * If true, than all previous selects are first reset, thus existing selects are * replaced. If false, than the new indexes will be added. * @return This */ public FormTester selectMultiple(String formComponentId, int[] indexes, final boolean replace) { checkClosed(); if (replace) { // Reset first setValue(formComponentId, ""); } ChoiceSelector choiceSelector = choiceSelectorFactory.createForMultiple((FormComponent<?>)workingForm.get(formComponentId)); for (int index : indexes) { choiceSelector.doSelect(index); } return this; } /** * Simulates filling in a field on a <code>Form</code>. * * @param formComponentId * relative path (from <code>Form</code>) to the selectable * <code>FormComponent</code> or <code>IFormSubmittingComponent</code> * @param value * the field value * @return This */ public FormTester setValue(final String formComponentId, final String value) { Component component = workingForm.get(formComponentId); Assert.assertNotNull("Unable to set value. Couldn't find component with name: " + formComponentId, component); return setValue(component, value); } /** * Simulates filling in a field on a <code>Form</code>. * * @param formComponent * relative path (from <code>Form</code>) to the selectable * <code>FormComponent</code> or <code>IFormSubmittingComponent</code> * @param value * the field value * @return This */ public FormTester setValue(final Component formComponent, final String value) { Args.notNull(formComponent, "formComponent"); checkClosed(); if (formComponent instanceof IFormSubmittingComponent) { setFormSubmittingComponentValue((IFormSubmittingComponent)formComponent, value); } else if (formComponent instanceof FormComponent) { setFormComponentValue((FormComponent<?>)formComponent, value); } else { fail("Component with id: " + formComponent.getId() + " is not a FormComponent"); } return this; } /** * @param checkBoxId * @param value * @return This */ public FormTester setValue(String checkBoxId, boolean value) { return setValue(checkBoxId, Boolean.toString(value)); } /** * Sets the <code>File</code> on a {@link FileUploadField}. * * @param formComponentId * relative path (from <code>Form</code>) to the selectable * <code>FormComponent</code>. The <code>FormComponent</code> must be of a type * <code>FileUploadField</code>. * @param file * the <code>File</code> to upload or {@code null} for an empty input * @param contentType * the content type of the file. Must be a valid mime type. * @return This */ public FormTester setFile(final String formComponentId, final File file, final String contentType) { checkClosed(); FormComponent<?> formComponent = (FormComponent<?>)workingForm.get(formComponentId); MockHttpServletRequest servletRequest = tester.getRequest(); if (formComponent instanceof FileUploadField) { servletRequest.addFile(formComponent.getInputName(), file, contentType); } else if (formComponent instanceof MultiFileUploadField) { String inputName = formComponent.getInputName() + MultiFileUploadField.MAGIC_SEPARATOR + multiFileUploadIndex++; servletRequest.addFile(inputName, file, contentType); } else { fail("'" + formComponentId + "' is not " + "a FileUploadField. You can only attach a file to form " + "component of this type."); } return this; } /** * Submits the <code>Form</code>. Note that <code>submit</code> can be executed only once. * * @return This */ public FormTester submit() { checkClosed(); try { if (clearFeedbackMessagesBeforeSubmit) { tester.clearFeedbackMessages(); } tester.getRequest().setUseMultiPartContentType(workingForm.isMultiPart()); tester.submitForm(path); } finally { closed = true; } return this; } public boolean isClearFeedbackMessagesBeforeSubmit() { return clearFeedbackMessagesBeforeSubmit; } public FormTester setClearFeedbackMessagesBeforeSubmit(boolean clearFeedbackMessagesBeforeSubmit) { this.clearFeedbackMessagesBeforeSubmit = clearFeedbackMessagesBeforeSubmit; return this; } /** * A convenience method for submitting the <code>Form</code> with an alternate button. * <p> * Note that if the button is associated with a model, it's better to use the * <code>setValue</code> method instead: * * <pre> * formTester.setValue("to:my:button", "value on the button"); * formTester.submit(); * </pre> * * @param buttonComponentId * relative path (from <code>Form</code>) to the button * @return This */ public FormTester submit(final String buttonComponentId) { Component submitter = getForm().get(buttonComponentId); if (submitter == null) { fail("Cannot submit the form because there is no submitting component with id: " + buttonComponentId); } return submit(submitter); } /** * A convenience method for submitting the <code>Form</code> with an alternate button. * <p> * Note that if the button is associated with a model, it's better to use the * <code>setValue</code> method instead: * * <pre> * formTester.setValue(myButton, "value on the button"); * formTester.submit(); * </pre> * * @param buttonComponent * relative path (from <code>Form</code>) to the button * @return This */ public FormTester submit(final Component buttonComponent) { Args.notNull(buttonComponent, "buttonComponent"); setValue(buttonComponent, "marked"); if (buttonComponent instanceof AjaxButton || buttonComponent instanceof AjaxSubmitLink) { if (clearFeedbackMessagesBeforeSubmit) { tester.clearFeedbackMessages(); } tester.getRequest().setUseMultiPartContentType(workingForm.isMultiPart()); tester.executeAjaxEvent(buttonComponent, "click"); return this; } else { return submit(); } } /** * A convenience method to submit the Form via a SubmitLink which may inside or outside of the * Form. * * @param path * The path to the SubmitLink * @param pageRelative * if true, than the 'path' to the SubmitLink is relative to the page. Thus the link * can be outside the form. If false, the path is relative to the form and thus the * link is inside the form. * @return This */ public FormTester submitLink(String path, final boolean pageRelative) { if (pageRelative) { tester.clickLink(path, false); } else { path = this.path + ":" + path; tester.clickLink(path, false); } return this; } /** * Adds an additional <code>FormComponent</code>'s value into request parameter -- this method * retains existing parameters but removes any duplicate parameters. * * @param formComponent * a <code>FormComponent</code> * @param value * a value to add * @return This */ private FormTester addFormComponentValue(FormComponent<?> formComponent, String value) { if (parameterExist(formComponent)) { List<StringValue> values = tester.getRequest() .getPostParameters() .getParameterValues(formComponent.getInputName()); // remove duplicated HashSet<String> all = new HashSet<String>(); for (StringValue val : values) { all.add(val.toString()); } all.add(value); values = new ArrayList<StringValue>(); for (String val : all) { values.add(StringValue.valueOf(val)); } tester.getRequest() .getPostParameters() .setParameterValues(formComponent.getInputName(), values); } else { setFormComponentValue(formComponent, value); } return this; } /** * <code>FormTester</code> must only be used once. Create a new instance of * <code>FormTester</code> for each test. */ private void checkClosed() { if (closed) { fail("'" + path + "' already submitted. Note that FormTester " + "is allowed to submit only once"); } } /** * Returns <code>true</code> if the parameter exists in the <code>FormComponent</code>. * * @param formComponent * a <code>FormComponent</code> * @return <code>true</code> if the parameter exists in the <code>FormComponent</code> */ private boolean parameterExist(final FormComponent<?> formComponent) { String parameter = tester.getRequest() .getPostParameters() .getParameterValue(formComponent.getInputName()) .toString(); return parameter != null && parameter.trim().length() > 0; } /** * Set formComponent's value into request parameter, this method overwrites existing parameters. * * @param formComponent * a <code>FormComponent</code> * @param value * a value to add */ private void setFormComponentValue(final FormComponent<?> formComponent, final String value) { tester.getRequest() .getPostParameters() .setParameterValue(formComponent.getInputName(), value); } /** * Set component's value into request parameter, this method overwrites existing parameters. * * @param component * an {@link IFormSubmittingComponent} * @param value * a value to add */ private void setFormSubmittingComponentValue(IFormSubmittingComponent component, String value) { tester.getRequest().getPostParameters().setParameterValue(component.getInputName(), value); } /** * * @param message */ private void fail(String message) { throw new WicketRuntimeException(message); } }