berlin.yuna.survey.logic.Survey Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of surveys Show documentation
Show all versions of surveys Show documentation
Survey is a plain java library to provide a base for surveys / questionnaires. It also provides a
function to generate diagrams and to measure answer times.
The newest version!
package berlin.yuna.survey.logic;
import berlin.yuna.survey.model.Condition;
import berlin.yuna.survey.model.ContextExchange;
import berlin.yuna.survey.model.HistoryItem;
import berlin.yuna.survey.model.HistoryItemBase;
import berlin.yuna.survey.model.HistoryItemJson;
import berlin.yuna.survey.model.types.FlowItem;
import java.time.Duration;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.stream.Collector;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import static berlin.yuna.survey.logic.CommonUtils.getTime;
import static berlin.yuna.survey.model.ContextExchange.contextOf;
import static berlin.yuna.survey.model.HistoryItemBase.State.ANSWERED;
import static berlin.yuna.survey.model.HistoryItemBase.State.CURRENT;
import static berlin.yuna.survey.model.HistoryItemBase.State.DRAFT;
import static berlin.yuna.survey.model.exception.QuestionNotFoundException.itemNotFound;
import static berlin.yuna.survey.model.exception.QuestionNotFoundException.itemNotFoundInHistory;
import static java.util.stream.Collectors.toCollection;
@SuppressWarnings({"unused", "UnusedReturnValue"})
public class Survey {
private static final String TRANSITION = "revert";
private FlowItem, ?> last;
private FlowItem, ?> flowStart;
private boolean autoBackTransition = true;
//https://stackoverflow.com/questions/4724995/lock-free-concurrent-linked-list-in-java
private final LinkedList history = new LinkedList<>();
/**
* Starts new SurveyCtx
*
* @param flowStart start item of the flow
* @return {@link Survey}
* @throws IllegalStateException on {@code null}
*/
public static Survey init(final FlowItem, ?> flowStart) {
return new Survey(flowStart);
}
/**
* Continues {@link Survey} from a history
* Removes all invalid {@link FlowItem} items from the history
*
* @param flowStart start item of the flow
* @param history should not be empty as {@link Survey} needs a start item
* @return {@link Survey}
* @throws IllegalStateException when the {@code history} is empty or has no valid {@link FlowItem}
*/
public static Survey init(final FlowItem, ?> flowStart, final Iterable extends HistoryItemBase>> history) {
final LinkedList linkedHistory = StreamSupport.stream(history.spliterator(), false)
.map(item -> HistoryItem.of(flowStart, item))
.flatMap(Optional::stream)
.collect(toCollection(LinkedList::new));
final Survey context = init(flowStart);
if (linkedHistory.isEmpty()) {
return init(flowStart);
}
context.history.clear();
context.history.addAll(linkedHistory);
context.last = context.findLast(linkedHistory);
context.flowStart = context.findFirst();
return context;
}
/**
* Transit to a specific {@link FlowItem} in the flow
*
* @param label for {@link FlowItem} to transition to
* @return {@code true} if transition is allowed, {@code false} on back transition config
* @throws IllegalArgumentException if the label is not part of the flow or when the forward transition has not enough answers
*/
public boolean transitTo(final String label) {
return transitTo(last.get(label).orElseThrow(() -> itemNotFound(label, flowStart.label())));
}
/**
* Transit to a specific {@link FlowItem} in the flow
*
* @param label for {@link FlowItem} to transition to
* @param context sets the context on {@link ContextExchange} which is used at {@link FlowItem#parse(ContextExchange)}
* @return {@code true} if transition is allowed, {@code false} on back transition config
* @throws IllegalArgumentException if the label is not part of the flow or when the forward transition has not enough answers
*/
public boolean transitTo(final String label, final Object context) {
return transitTo(last.get(label).orElseThrow(() -> itemNotFound(label, flowStart.label())), context);
}
/**
* Transit to a specific {@link FlowItem} in the flow
*
* @param target {@link FlowItem} to transition to
* @return {@code true} if transition is allowed, {@code false} on config of {@link FlowItem#onBack(Condition[])}
* @throws IllegalArgumentException if the label is not part of the flow or when the forward transition has not
* enough answers (will transition to the nearest possible {@link FlowItem})
*/
public boolean transitTo(final FlowItem, ?> target) {
return transitTo(target, null);
}
/**
* Transit to a specific {@link FlowItem} in the flow
*
* @param target {@link FlowItem} to transition to
* @param context sets the context on {@link ContextExchange} which is used at {@link FlowItem#parse(ContextExchange)}
* @return {@code true} if transition is allowed, {@code false} on config of {@link FlowItem#onBack(Condition[])}
* @throws IllegalArgumentException if the label is not part of the flow or when the forward transition has not
* enough answers (will transition to the nearest possible {@link FlowItem})
*/
public boolean transitTo(final FlowItem, ?> target, final Object context) {
if (target.equals(get())) {
return true;
}
boolean result = true;
assertQuestionBelongsToFlow(target);
if (history.stream().filter(HistoryItem::isNotDraft).anyMatch(target::match)) {
result = runBackTransitions(target, context);
} else {
runForwardTransitions(target, context);
}
return result;
}
/**
* Get current {@link FlowItem} of the flow
*
* @return {@link FlowItem} of the current flow
*/
public FlowItem, ?> get() {
return last;
}
/**
* Get a flow item by the given {@code String}
* To avoid cast its recommended to use {@link FlowItem#get(FlowItem)}
*
* @param label The {@code label} to search in flow
* @return {@link Optional} or {@code null} when flow doesn't contain the
* requested item
*/
public FlowItem, ?> get(final String label) {
return flowStart.get(label).orElse(null);
}
/**
* Get a flow item by the given {@code enum}
* To avoid cast its recommended to use {@link FlowItem#get(FlowItem)}
*
* @param label The {@code label} to search in flow
* @return {@link FlowItem} or {@code null} when flow doesn't contain the
* requested item
*/
public FlowItem, ?> get(final Enum> label) {
return flowStart.get(label).orElse(null);
}
/**
* Get a flow item by the given {@link FlowItem}
*
* @param type {@link FlowItem} to search in flow
* @return {@link FlowItem} or {@code null} when flow doesn't contain the
* requested item
*/
public > I get(final I type) {
return flowStart.get(type).orElse(null);
}
/**
* Get previous {@link FlowItem} from the flow
*
* @return previous {@link FlowItem} and {@code null} if there is no previous entry
*/
public FlowItem, ?> getPrevious() {
return last.parents().stream().filter(q -> getHistoryAnswered().anyMatch(item -> item.match(q))).findFirst().orElse(null);
}
/**
* Get first {@link FlowItem} of the flow
*
* @return first {@link FlowItem} of the current flow
*/
public FlowItem, ?> getFirst() {
return flowStart;
}
/**
* Check if the current flow has ended
*
* @return true if there is no next {@link FlowItem}
*/
public boolean isEnded() {
return last.targets().isEmpty() && getHistoryAnswered().filter(HistoryItem::isNotDraft).anyMatch(answer -> answer.match(last));
}
/**
* Get history of answers
*
* @return all answers which were given in the context
*/
public List getHistory() {
return new LinkedList<>(history);
}
/**
* Get history of answers
*
* @return all answers as json format which were given in the context
*/
public List getHistoryJson() {
return history.stream().map(item -> HistoryItemJson.of(flowStart, item)).flatMap(Optional::stream).collect(toCollection(LinkedList::new));
}
/**
* Get history size
*
* @return number of given answers
*/
public int getHistorySize() {
return history.size();
}
/**
* Solves the current {@link FlowItem} of the flow
*
* @param answer answer to solve the current {@link FlowItem}
* @return {@link Survey}
*/
public Survey answer(final Object answer) {
return answer(answer, null);
}
/**
* Solves the current {@link FlowItem} of the flow
*
* @param answer answer to solve the current {@link FlowItem}
* @param context sets the context on {@link ContextExchange} which is used at {@link FlowItem#parse(ContextExchange)}
* @return {@link Survey}
*/
public Survey answer(final Object answer, final Object context) {
return answer instanceof ContextExchange ? answer((ContextExchange) answer, true) :
answer(contextOf(this, answer, context), true);
}
/**
* Returns times taken between each question useful to track answer times
*
* @return times of answered questions
*/
public Map getDurationsMS() {
final Map result = new LinkedHashMap<>();
final AtomicReference lastTime = new AtomicReference<>(null);
getHistoryAnswered().sorted().filter(HistoryItem::isNotDraft).forEach(item -> {
Optional.ofNullable(lastTime.get()).ifPresent(
lastT -> result.put(lastT.getLabel(), Duration.between(lastT.getCreatedAt(), item.getCreatedAt()).toMillis())
);
lastTime.set(item);
});
return result;
}
/**
* Defines if back transitions are allowed for non configured back conditions
*
* @param enableAutomatic {@code true} on default
* @return {@link Survey}
*/
public Survey autoBackTransition(final boolean enableAutomatic) {
autoBackTransition = enableAutomatic;
return this;
}
/**
* Definition if back transitions are allowed for non configured back conditions
*
* @return {@code true} on default
*/
public boolean hasAutoBackTransition() {
return autoBackTransition;
}
/**
* Prepares diagram renderer
*
* @return {@link DiagramExporter} renderer
*/
public DiagramExporter diagram() {
return new DiagramExporter(this);
}
protected FlowItem, ?> findLast(final LinkedList historySorted) {
final String label = historySorted.stream()
.filter(HistoryItem::isNotAnswered).findFirst()
.map(HistoryItem::getLabel)
.orElse(historySorted.getLast().getLabel());
return flowStart.get(label).orElseThrow(() -> itemNotFoundInHistory(label, flowStart.label()));
}
/**
* Find first {@link FlowItem} of the flow
*
* @return first {@link FlowItem} of the current flow
*/
private FlowItem, ?> findFirst() {
return flowStart.get(history.getFirst().getLabel()).orElseThrow(() -> itemNotFound(history.getFirst().getLabel(), flowStart.label()));
}
private Stream getHistoryAnswered() {
return history.stream().filter(HistoryItem::isAnswered);
}
private void markAsCurrent(final String label) {
final HistoryItem historyItem = getOrCreateAnswer(label);
historyItem.setState(CURRENT);
}
private void markAsDraft(final String label) {
getOrCreateAnswer(label).setState(DRAFT);
}
private void markAsAnswered(final String label, final ContextExchange context, final boolean upDate) {
final HistoryItem historyItem = getOrCreateAnswer(label);
if (upDate || historyItem.isNotAnswered()) {
historyItem.setCreatedAt(getTime());
}
historyItem.setState(ANSWERED);
historyItem.setAnswer(context.payload());
}
private HistoryItem getOrCreateAnswer(final String label) {
final HistoryItem answer = new HistoryItem(label);
final int index = history.indexOf(answer);
if (index == -1) {
history.add(answer);
return answer;
}
return history.get(index);
}
private Survey(final FlowItem, ?> startQuestion) {
assertExists(startQuestion);
this.last = startQuestion;
markAsCurrent(last.label());
flowStart = startQuestion;
}
private void assertExists(final FlowItem, ?> startQuestion) {
if (startQuestion == null) {
throw new IllegalArgumentException("Missing " + FlowItem.class.getSimpleName() + ", given was null");
}
}
private void runForwardTransitions(final FlowItem, ?> target, final Object context) {
final Set checkedLabel = new HashSet<>();
final Map mappedHistory = getHistoryAnswered().collect(toLinkedMap(HistoryItem::getLabel, HistoryItem::getAnswer));
String label = flowStart.label();
do {
final FlowItem, ?> currentQuestion = answer(contextOf(this, mappedHistory.get(label), context, target).put(TRANSITION, false), false).get();
label = currentQuestion.label();
if (checkedLabel.contains(label)) {
//FIXME: custom checked exception
throw new IllegalArgumentException(
"Unable transition to [" + target.label() + "] "
+ "could not solve [" + label + "] "
+ (mappedHistory.containsKey(label)? "by given answer [" + mappedHistory.get(label) + "] "
: "no history item found for this label")
);
}
checkedLabel.add(label);
last = currentQuestion;
} while (!label.equals(target.label()));
if (mappedHistory.containsKey(target.label())) {
answer(contextOf(this, mappedHistory.get(label), context, target).put(TRANSITION, false), false);
}
}
private boolean runBackTransitions(final FlowItem, ?> target, final Object context) {
final Iterator iterator = new LinkedList<>(history).descendingIterator();
while (iterator.hasNext()) {
final HistoryItem answer = iterator.next();
if (answer.isCurrent()) {
history.remove(answer);
continue;
}
if (answer.getLabel().equals(target.label())) {
markAsCurrent(answer.getLabel());
last = flowStart.getOrElse(answer.getLabel(), last);
return true;
}
final boolean revertIsAllowed = flowStart.get(answer.getLabel())
.flatMap(q -> q.parseAndRevert(contextOf(this, answer.getAnswer(), context, target).put(TRANSITION, true)))
.orElse(autoBackTransition);
if (revertIsAllowed) {
markAsDraft(answer.getLabel());
last = flowStart.getOrElse(answer.getLabel(), last);
} else {
markAsCurrent(last.label());
return false;
}
}
markAsCurrent(last.label());
return true;
}
private Survey answer(final ContextExchange context, final boolean upDate) {
final Optional> result = last.parseAndAnswer(context);
markAsAnswered(last.label(), context, upDate);
if (result.isPresent()) {
last = result.get();
if (upDate && !isEnded()) {
markAsCurrent(last.label());
}
}
return this;
}
private void assertQuestionBelongsToFlow(final FlowItem, ?> question) {
assertExists(question);
if (flowStart.get(question.label()).isEmpty()) {
throw itemNotFoundInHistory(question.label(), flowStart.label());
}
}
private static Collector> toLinkedMap(
final Function super T, ? extends K> keyMapper,
final Function super T, ? extends U> valueMapper) {
return Collectors.toMap(
keyMapper,
valueMapper,
(u, v) -> {
throw new IllegalStateException(String.format("Duplicate key %s", u));
},
LinkedHashMap::new
);
}
}