Bug 52962 - Allow sorting by columns for View Results in Table, Summary Report, Aggregate Report and Aggregate Graph

Based on a contribution by Logan Mauzaize (logan.mauzaize at gmail.com) and Maxime Chassagneux
This closes github pr #245
Bugzilla Id: 52962

git-svn-id: https://svn.apache.org/repos/asf/jmeter/trunk@1778767 13f79535-47bb-0310-9956-ffa450edef68

Former-commit-id: 343a9428b1
This commit is contained in:
Philippe Mouawad 2017-01-14 13:18:06 +00:00
parent 52654fada2
commit ca31116432
12 changed files with 1232 additions and 35 deletions

View File

@ -69,7 +69,7 @@ import org.apache.jmeter.gui.action.ActionRouter;
import org.apache.jmeter.gui.action.SaveGraphics;
import org.apache.jmeter.gui.util.FileDialoger;
import org.apache.jmeter.gui.util.FilePanel;
import org.apache.jmeter.gui.util.HeaderAsPropertyRenderer;
import org.apache.jmeter.gui.util.HeaderAsPropertyRendererWrapper;
import org.apache.jmeter.gui.util.VerticalPanel;
import org.apache.jmeter.samplers.Clearable;
import org.apache.jmeter.samplers.SampleResult;
@ -80,6 +80,7 @@ import org.apache.jorphan.gui.GuiUtils;
import org.apache.jorphan.gui.JLabeledTextField;
import org.apache.jorphan.gui.NumberRenderer;
import org.apache.jorphan.gui.ObjectTableModel;
import org.apache.jorphan.gui.ObjectTableSorter;
import org.apache.jorphan.gui.RateRenderer;
import org.apache.jorphan.gui.RendererUtils;
import org.apache.jorphan.logging.LoggingManager;
@ -94,7 +95,7 @@ import org.apache.log.Logger;
*
*/
public class StatGraphVisualizer extends AbstractVisualizer implements Clearable, ActionListener {
private static final long serialVersionUID = 240L;
private static final long serialVersionUID = 241L;
private static final String PCT1_LABEL = JMeterUtils.getPropDefault("aggregate_rpt_pct1", "90");
private static final String PCT2_LABEL = JMeterUtils.getPropDefault("aggregate_rpt_pct2", "95");
@ -319,8 +320,8 @@ public class StatGraphVisualizer extends AbstractVisualizer implements Clearable
new Functor("getSentKBPerSecond") }, //$NON-NLS-1$
new Functor[] { null, null, null, null, null, null, null, null, null, null, null, null, null },
new Class[] { String.class, Long.class, Long.class, Long.class, Long.class,
Long.class, Long.class, Long.class, Long.class, String.class,
String.class, String.class, String.class});
Long.class, Long.class, Long.class, Long.class, Double.class,
Double.class, Double.class, Double.class});
}
// Column formats
@ -467,9 +468,10 @@ public class StatGraphVisualizer extends AbstractVisualizer implements Clearable
mainPanel.add(makeTitlePanel());
myJTable = new JTable(model);
myJTable.setRowSorter(new ObjectTableSorter(model).fixLastRow());
JMeterUtils.applyHiDPI(myJTable);
// Fix centering of titles
myJTable.getTableHeader().setDefaultRenderer(new HeaderAsPropertyRenderer(getColumnsMsgParameters()));
HeaderAsPropertyRendererWrapper.setupDefaultRenderer(myJTable, getColumnsMsgParameters());
myJTable.setPreferredScrollableViewportSize(new Dimension(500, 70));
RendererUtils.applyRenderers(myJTable, getRenderers());
myScrollPane = new JScrollPane(myJTable);
@ -503,6 +505,7 @@ public class StatGraphVisualizer extends AbstractVisualizer implements Clearable
});
spane = new JSplitPane(JSplitPane.VERTICAL_SPLIT);
spane.setOneTouchExpandable(true);
spane.setLeftComponent(myScrollPane);
spane.setRightComponent(tabbedGraph);
spane.setResizeWeight(.2);

View File

@ -40,7 +40,7 @@ import javax.swing.border.Border;
import javax.swing.border.EmptyBorder;
import org.apache.jmeter.gui.util.FileDialoger;
import org.apache.jmeter.gui.util.HeaderAsPropertyRenderer;
import org.apache.jmeter.gui.util.HeaderAsPropertyRendererWrapper;
import org.apache.jmeter.samplers.Clearable;
import org.apache.jmeter.samplers.SampleResult;
import org.apache.jmeter.save.CSVSaveService;
@ -48,6 +48,7 @@ import org.apache.jmeter.testelement.TestElement;
import org.apache.jmeter.util.JMeterUtils;
import org.apache.jmeter.visualizers.gui.AbstractVisualizer;
import org.apache.jorphan.gui.ObjectTableModel;
import org.apache.jorphan.gui.ObjectTableSorter;
import org.apache.jorphan.gui.RendererUtils;
/**
@ -59,7 +60,7 @@ import org.apache.jorphan.gui.RendererUtils;
*/
public class StatVisualizer extends AbstractVisualizer implements Clearable, ActionListener {
private static final long serialVersionUID = 240L;
private static final long serialVersionUID = 241L;
private static final String USE_GROUP_NAME = "useGroupName"; //$NON-NLS-1$
@ -172,8 +173,9 @@ public class StatVisualizer extends AbstractVisualizer implements Clearable, Act
mainPanel.add(makeTitlePanel());
myJTable = new JTable(model);
myJTable.setRowSorter(new ObjectTableSorter(model).fixLastRow());
JMeterUtils.applyHiDPI(myJTable);
myJTable.getTableHeader().setDefaultRenderer(new HeaderAsPropertyRenderer(StatGraphVisualizer.getColumnsMsgParameters()));
HeaderAsPropertyRendererWrapper.setupDefaultRenderer(myJTable, StatGraphVisualizer.getColumnsMsgParameters());
myJTable.setPreferredScrollableViewportSize(new Dimension(500, 70));
RendererUtils.applyRenderers(myJTable, StatGraphVisualizer.getRenderers());
myScrollPane = new JScrollPane(myJTable);
@ -219,4 +221,3 @@ public class StatVisualizer extends AbstractVisualizer implements Clearable, Act
}
}
}

View File

@ -43,7 +43,7 @@ import javax.swing.border.EmptyBorder;
import javax.swing.table.TableCellRenderer;
import org.apache.jmeter.gui.util.FileDialoger;
import org.apache.jmeter.gui.util.HeaderAsPropertyRenderer;
import org.apache.jmeter.gui.util.HeaderAsPropertyRendererWrapper;
import org.apache.jmeter.samplers.Clearable;
import org.apache.jmeter.samplers.SampleResult;
import org.apache.jmeter.save.CSVSaveService;
@ -53,6 +53,7 @@ import org.apache.jmeter.util.JMeterUtils;
import org.apache.jmeter.visualizers.gui.AbstractVisualizer;
import org.apache.jorphan.gui.NumberRenderer;
import org.apache.jorphan.gui.ObjectTableModel;
import org.apache.jorphan.gui.ObjectTableSorter;
import org.apache.jorphan.gui.RateRenderer;
import org.apache.jorphan.gui.RendererUtils;
import org.apache.jorphan.reflect.Functor;
@ -63,7 +64,7 @@ import org.apache.jorphan.reflect.Functor;
*/
public class SummaryReport extends AbstractVisualizer implements Clearable, ActionListener {
private static final long serialVersionUID = 240L;
private static final long serialVersionUID = 241L;
private static final String USE_GROUP_NAME = "useGroupName"; //$NON-NLS-1$
@ -158,8 +159,8 @@ public class SummaryReport extends AbstractVisualizer implements Clearable, Acti
new Functor("getAvgPageBytes"), //$NON-NLS-1$
},
new Functor[] { null, null, null, null, null, null, null, null , null, null, null },
new Class[] { String.class, Long.class, Long.class, Long.class, Long.class,
String.class, String.class, String.class, String.class, String.class, String.class });
new Class[] { String.class, Integer.class, Long.class, Long.class, Long.class,
Double.class, Double.class, Double.class, Double.class, Double.class, Double.class });
clearData();
init();
}
@ -239,8 +240,9 @@ public class SummaryReport extends AbstractVisualizer implements Clearable, Acti
mainPanel.add(makeTitlePanel());
myJTable = new JTable(model);
myJTable.setRowSorter(new ObjectTableSorter(model).fixLastRow());
JMeterUtils.applyHiDPI(myJTable);
myJTable.getTableHeader().setDefaultRenderer(new HeaderAsPropertyRenderer());
HeaderAsPropertyRendererWrapper.setupDefaultRenderer(myJTable);
myJTable.setPreferredScrollableViewportSize(new Dimension(500, 70));
RendererUtils.applyRenderers(myJTable, RENDERERS);
myScrollPane = new JScrollPane(myJTable);

View File

@ -23,6 +23,7 @@ import java.awt.Color;
import java.awt.FlowLayout;
import java.text.Format;
import java.text.SimpleDateFormat;
import java.util.Comparator;
import javax.swing.BorderFactory;
import javax.swing.ImageIcon;
@ -37,7 +38,7 @@ import javax.swing.border.EmptyBorder;
import javax.swing.table.TableCellRenderer;
import org.apache.jmeter.JMeter;
import org.apache.jmeter.gui.util.HeaderAsPropertyRenderer;
import org.apache.jmeter.gui.util.HeaderAsPropertyRendererWrapper;
import org.apache.jmeter.gui.util.HorizontalPanel;
import org.apache.jmeter.samplers.Clearable;
import org.apache.jmeter.samplers.SampleResult;
@ -45,6 +46,7 @@ import org.apache.jmeter.util.Calculator;
import org.apache.jmeter.util.JMeterUtils;
import org.apache.jmeter.visualizers.gui.AbstractVisualizer;
import org.apache.jorphan.gui.ObjectTableModel;
import org.apache.jorphan.gui.ObjectTableSorter;
import org.apache.jorphan.gui.RendererUtils;
import org.apache.jorphan.gui.RightAlignRenderer;
import org.apache.jorphan.gui.layout.VerticalLayout;
@ -58,7 +60,7 @@ import org.apache.jorphan.reflect.Functor;
*/
public class TableVisualizer extends AbstractVisualizer implements Clearable {
private static final long serialVersionUID = 240L;
private static final long serialVersionUID = 241L;
private static final String ICON_SIZE = JMeterUtils.getPropDefault(JMeter.TREE_ICON_SIZE, JMeter.DEFAULT_TREE_ICON_SIZE);
@ -185,10 +187,10 @@ public class TableVisualizer extends AbstractVisualizer implements Clearable {
calc.addSample(res);
int count = calc.getCount();
TableSample newS = new TableSample(
count,
res.getSampleCount(),
res.getStartTime(),
res.getThreadName(),
count,
res.getSampleCount(),
res.getStartTime(),
res.getThreadName(),
res.getSampleLabel(),
res.getTime(),
res.isSuccessful(),
@ -238,8 +240,22 @@ public class TableVisualizer extends AbstractVisualizer implements Clearable {
// Set up the table itself
table = new JTable(model);
table.setRowSorter(new ObjectTableSorter(model).setValueComparator(5,
Comparator.nullsFirst(
(ImageIcon o1, ImageIcon o2) -> {
if (o1 == o2) {
return 0;
}
if (o1 == imageSuccess) {
return -1;
}
if (o1 == imageFailure) {
return 1;
}
throw new IllegalArgumentException("Only success and failure images can be compared");
})));
JMeterUtils.applyHiDPI(table);
table.getTableHeader().setDefaultRenderer(new HeaderAsPropertyRenderer());
HeaderAsPropertyRendererWrapper.setupDefaultRenderer(table);
RendererUtils.applyRenderers(table, RENDERERS);
tableScrollPanel = new JScrollPane(table);

View File

@ -78,6 +78,19 @@ public class HeaderAsPropertyRenderer extends DefaultTableCellRenderer {
* @return the text
*/
protected String getText(Object value, int row, int column) {
return getText(value, row, column, columnsMsgParameters);
}
/**
* Get the text for the value as the translation of the resource name.
*
* @param value value for which to get the translation
* @param column index which column message parameters should be used
* @param row not used
* @param columnsMsgParameters
* @return the text
*/
static String getText(Object value, int row, int column, Object[][] columnsMsgParameters) {
if (value == null){
return "";
}

View File

@ -0,0 +1,89 @@
/*
* 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.jmeter.gui.util;
import java.awt.Component;
import java.io.Serializable;
import javax.swing.JTable;
import javax.swing.SwingConstants;
import javax.swing.UIManager;
import javax.swing.table.DefaultTableCellRenderer;
import javax.swing.table.JTableHeader;
import javax.swing.table.TableCellRenderer;
/**
* Wraps {@link TableCellRenderer} to renders items in a JTable by using resource names
* and control some formatting (centering, fonts and border)
*/
public class HeaderAsPropertyRendererWrapper implements TableCellRenderer, Serializable {
private static final long serialVersionUID = 240L;
private Object[][] columnsMsgParameters;
private TableCellRenderer delegate;
/**
* @param columnsMsgParameters Optional parameters of i18n keys
*/
public HeaderAsPropertyRendererWrapper(TableCellRenderer renderer, Object[][] columnsMsgParameters) {
this.delegate = renderer;
this.columnsMsgParameters = columnsMsgParameters;
}
@Override
public Component getTableCellRendererComponent(JTable table, Object value,
boolean isSelected, boolean hasFocus, int row, int column) {
if(delegate instanceof DefaultTableCellRenderer) {
DefaultTableCellRenderer tr = (DefaultTableCellRenderer) delegate;
if (table != null) {
JTableHeader header = table.getTableHeader();
if (header != null){
tr.setForeground(header.getForeground());
tr.setBackground(header.getBackground());
tr.setFont(header.getFont());
}
}
tr.setBorder(UIManager.getBorder("TableHeader.cellBorder"));
tr.setHorizontalAlignment(SwingConstants.CENTER);
}
return delegate.getTableCellRendererComponent(table,
HeaderAsPropertyRenderer.getText(value, row, column, columnsMsgParameters),
isSelected, hasFocus, row, column);
}
/**
*
* @param table {@link JTable}
*/
public static void setupDefaultRenderer(JTable table) {
setupDefaultRenderer(table, null);
}
/**
* @param table {@link JTable}
* @param columnsMsgParameters Double dimension array of column message parameters
*/
public static void setupDefaultRenderer(JTable table, Object[][] columnsMsgParameters) {
TableCellRenderer defaultRenderer = table.getTableHeader().getDefaultRenderer();
HeaderAsPropertyRendererWrapper newRenderer = new HeaderAsPropertyRendererWrapper(defaultRenderer, columnsMsgParameters);
table.getTableHeader().setDefaultRenderer(newRenderer);
}
}

View File

@ -23,7 +23,6 @@ import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import javax.swing.event.TableModelEvent;
import javax.swing.table.DefaultTableModel;
import org.apache.jorphan.logging.LoggingManager;
@ -134,9 +133,8 @@ public class ObjectTableModel extends DefaultTableModel {
}
public void clearData() {
int size = getRowCount();
objects.clear();
super.fireTableRowsDeleted(0, size);
super.fireTableDataChanged();
}
public void addRow(Object value) {
@ -149,12 +147,12 @@ public class ObjectTableModel extends DefaultTableModel {
}
}
objects.add(value);
super.fireTableRowsInserted(objects.size() - 1, objects.size());
super.fireTableRowsInserted(objects.size() - 1, objects.size() - 1);
}
public void insertRow(Object value, int index) {
objects.add(index, value);
super.fireTableRowsInserted(index, index + 1);
super.fireTableRowsInserted(index, index);
}
/** {@inheritDoc} */
@ -202,12 +200,11 @@ public class ObjectTableModel extends DefaultTableModel {
/** {@inheritDoc} */
@Override
public void moveRow(int start, int end, int to) {
List<Object> subList = new ArrayList<>(objects.subList(start, end));
for (int x = end - 1; x >= start; x--) {
objects.remove(x);
}
objects.addAll(to, subList);
super.fireTableChanged(new TableModelEvent(this));
List<Object> subList = objects.subList(start, end);
List<Object> backup = new ArrayList<>(subList);
subList.clear();
objects.addAll(to, backup);
super.fireTableDataChanged();
}
/** {@inheritDoc} */
@ -292,9 +289,19 @@ public class ObjectTableModel extends DefaultTableModel {
return status;
}
/**
* @return Object (List of Object)
*/
public Object getObjectList() { // used by TableEditor
return objects;
}
/**
* @return List of Object
*/
public List<Object> getObjectListAsList() {
return objects;
}
public void setRows(Iterable<?> rows) { // used by TableEditor
clearData();

View File

@ -0,0 +1,356 @@
/*
* 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.jorphan.gui;
import static java.lang.String.format;
import java.text.Collator;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import javax.swing.RowSorter;
import javax.swing.SortOrder;
/**
* Implementation of a {@link RowSorter} for {@link ObjectTableModel}
* @since 3.2
*
*/
public class ObjectTableSorter extends RowSorter<ObjectTableModel> {
/**
* View row with model mapping. All data relates to model.
*/
public class Row {
private int index;
protected Row(int index) {
this.index = index;
}
public int getIndex() {
return index;
}
public Object getValue() {
return getModel().getObjectListAsList().get(getIndex());
}
public Object getValueAt(int column) {
return getModel().getValueAt(getIndex(), column);
}
}
protected class PreserveLastRowComparator implements Comparator<Row> {
@Override
public int compare(Row o1, Row o2) {
int lastIndex = model.getRowCount() - 1;
if (o1.getIndex() >= lastIndex || o2.getIndex() >= lastIndex) {
return o1.getIndex() - o2.getIndex();
}
return 0;
}
}
private ObjectTableModel model;
private SortKey sortkey;
private Comparator<Row> comparator = null;
private ArrayList<Row> viewToModel = new ArrayList<>();
private int[] modelToView = new int[0];
private Comparator<Row> primaryComparator = null;
private Comparator<?>[] valueComparators;
private Comparator<Row> fallbackComparator;
public ObjectTableSorter(ObjectTableModel model) {
this.model = model;
this.valueComparators = new Comparator<?>[this.model.getColumnCount()];
IntStream.range(0, this.valueComparators.length).forEach(i -> this.setValueComparator(i, null));
setFallbackComparator(null);
}
/**
* Comparator used prior to sorted columns.
*/
public Comparator<Row> getPrimaryComparator() {
return primaryComparator;
}
/**
* Comparator used on sorted columns.
*/
public Comparator<?> getValueComparator(int column) {
return valueComparators[column];
}
/**
* Comparator if all sorted columns matches. Defaults to model index comparison.
*/
public Comparator<Row> getFallbackComparator() {
return fallbackComparator;
}
/**
* Comparator used prior to sorted columns.
* @return <code>this</code>
*/
public ObjectTableSorter setPrimaryComparator(Comparator<Row> primaryComparator) {
invalidate();
this.primaryComparator = primaryComparator;
return this;
}
/**
* Sets {@link #getPrimaryComparator() primary comparator} to one that don't sort last row.
* @return <code>this</code>
*/
public ObjectTableSorter fixLastRow() {
return setPrimaryComparator(new PreserveLastRowComparator());
}
/**
* Assign comparator to given column, if <code>null</code> a {@link #getDefaultComparator(int) default one} is used instead.
* @param column Model column index.
* @param comparator Column value comparator.
* @return <code>this</code>
*/
public ObjectTableSorter setValueComparator(int column, Comparator<?> comparator) {
invalidate();
if (comparator == null) {
comparator = getDefaultComparator(column);
}
valueComparators[column] = comparator;
return this;
}
/**
* Builds a default comparator based on model column class. {@link Collator#getInstance()} for {@link String},
* {@link Comparator#naturalOrder() natural order} for {@link Comparable}, no sort support for others.
* @param column Model column index.
*/
protected Comparator<?> getDefaultComparator(int column) {
Class<?> columnClass = model.getColumnClass(column);
if (columnClass == null) {
return null;
}
if (columnClass == String.class) {
return Comparator.nullsFirst(Collator.getInstance());
}
if (Comparable.class.isAssignableFrom(columnClass)) {
return Comparator.nullsFirst(Comparator.naturalOrder());
}
return null;
}
/**
* Sets a fallback comparator (defaults to model index comparison) if none {@link #getPrimaryComparator() primary}, neither {@link #getValueComparator(int) column value comparators} can make differences between two rows.
* @return <code>this</code>
*/
public ObjectTableSorter setFallbackComparator(Comparator<Row> comparator) {
invalidate();
if (comparator == null) {
comparator = Comparator.comparingInt(Row::getIndex);
}
fallbackComparator = comparator;
return this;
}
@Override
public ObjectTableModel getModel() {
return model;
}
@Override
public void toggleSortOrder(int column) {
SortKey newSortKey;
if (isSortable(column)) {
SortOrder newOrder = sortkey == null || sortkey.getColumn() != column
|| sortkey.getSortOrder() != SortOrder.ASCENDING ? SortOrder.ASCENDING : SortOrder.DESCENDING;
newSortKey = new SortKey(column, newOrder);
} else {
newSortKey = null;
}
setSortKey(newSortKey);
}
@Override
public int convertRowIndexToModel(int index) {
if (!isSorted()) {
return index;
}
validate();
return viewToModel.get(index).getIndex();
}
@Override
public int convertRowIndexToView(int index) {
if (!isSorted()) {
return index;
}
validate();
return modelToView[index];
}
@Override
public void setSortKeys(List<? extends SortKey> keys) {
switch (keys.size()) {
case 0:
setSortKey(null);
break;
case 1:
setSortKey(keys.get(0));
break;
default:
throw new IllegalArgumentException("Only one column can be sorted");
}
}
public void setSortKey(SortKey sortkey) {
if (Objects.equals(this.sortkey, sortkey)) {
return;
}
invalidate();
if (sortkey != null) {
int column = sortkey.getColumn();
Comparator<?> comparator = valueComparators[column];
if (comparator == null) {
throw new IllegalArgumentException(format("Can't sort column %s, it is mapped to type %s and this one have no natural order. So an explicit one must be specified", column, model.getColumnClass(column)));
}
}
this.sortkey = sortkey;
this.comparator = null;
}
@Override
public List<? extends SortKey> getSortKeys() {
return isSorted() ? Collections.singletonList(sortkey) : Collections.emptyList();
}
@Override
public int getViewRowCount() {
return getModelRowCount();
}
@Override
public int getModelRowCount() {
return model.getRowCount();
}
@Override
public void modelStructureChanged() {
setSortKey(null);
}
@Override
public void allRowsChanged() {
invalidate();
}
@Override
public void rowsInserted(int firstRow, int endRow) {
rowsChanged(firstRow, endRow, false, true);
}
@Override
public void rowsDeleted(int firstRow, int endRow) {
rowsChanged(firstRow, endRow, true, false);
}
@Override
public void rowsUpdated(int firstRow, int endRow) {
rowsChanged(firstRow, endRow, true, true);
}
protected void rowsChanged(int firstRow, int endRow, boolean deleted, boolean inserted) {
invalidate();
}
@Override
public void rowsUpdated(int firstRow, int endRow, int column) {
if (isSorted(column)) {
rowsUpdated(firstRow, endRow);
}
}
protected boolean isSortable(int column) {
return getValueComparator(column) != null;
}
protected boolean isSorted(int column) {
return isSorted() && sortkey.getColumn() == column && sortkey.getSortOrder() != SortOrder.UNSORTED;
}
protected boolean isSorted() {
return sortkey != null;
}
protected void invalidate() {
viewToModel.clear();
modelToView = new int[0];
}
protected void validate() {
if (isSorted() && viewToModel.isEmpty()) {
sort();
}
}
@SuppressWarnings({ "rawtypes", "unchecked" })
protected Comparator<Row> getComparatorFromSortKey(SortKey sortkey) {
Comparator comparator = getValueComparator(sortkey.getColumn());
if (sortkey.getSortOrder() == SortOrder.DESCENDING) {
comparator = comparator.reversed();
}
Function<Row,Object> getValueAt = (Row row) -> row.getValueAt(sortkey.getColumn());
return Comparator.comparing(getValueAt, comparator);
}
protected void sort() {
if (comparator == null) {
comparator = Stream.concat(
Stream.concat(
getPrimaryComparator() != null ? Stream.of(getPrimaryComparator()) : Stream.<Comparator<Row>>empty(),
getSortKeys().stream().filter(sk -> sk != null && sk.getSortOrder() != SortOrder.UNSORTED).map(this::getComparatorFromSortKey)
),
Stream.of(getFallbackComparator())
).reduce(comparator, (result, current) -> result != null ? result.thenComparing(current) : current);
}
viewToModel.clear();
viewToModel.ensureCapacity(model.getRowCount());
IntStream.range(0, model.getRowCount()).mapToObj(i -> new Row(i)).forEach(viewToModel::add);
Collections.sort(viewToModel, comparator);
updateModelToView();
}
protected void updateModelToView() {
modelToView = new int[viewToModel.size()];
IntStream.range(0, viewToModel.size()).forEach(viewIndex -> modelToView[viewToModel.get(viewIndex).getIndex()] = viewIndex);
}
}

View File

@ -0,0 +1,251 @@
/*
* 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.jorphan.gui;
import static java.lang.String.format;
import static java.util.stream.IntStream.range;
import static org.junit.Assert.assertEquals;
import java.util.Arrays;
import java.util.stream.IntStream;
import javax.swing.event.TableModelEvent;
import org.apache.jorphan.reflect.Functor;
import org.junit.Before;
import org.junit.Test;
public class ObjectTableModelTest {
public static class Dummy {
String a;
String b;
String c;
Dummy(String a, String b, String c) {
this.a = a;
this.b = b;
this.c = c;
}
public String getA() {
return a;
}
public String getB() {
return b;
}
public String getC() {
return c;
}
}
ObjectTableModel model;
TableModelEventBacker events;
@Before
public void init() {
String[] headers = { "a", "b", "c" };
Functor[] readFunctors = Arrays.stream(headers).map(name -> "get" + name.toUpperCase()).map(Functor::new).toArray(n -> new Functor[n]);
Functor[] writeFunctors = new Functor[headers.length];
Class<?>[] editorClasses = new Class<?>[headers.length];
Arrays.fill(editorClasses, String.class);
model = new ObjectTableModel(headers, readFunctors, writeFunctors, editorClasses);
events = new TableModelEventBacker();
}
@Test
public void checkAddRow() {
model.addTableModelListener(events);
assertModel();
model.addRow(new Dummy("1", "1", "1"));
assertModel("1");
events.assertEvents(
events.assertEvent()
.source(model)
.type(TableModelEvent.INSERT)
.column(TableModelEvent.ALL_COLUMNS)
.firstRow(0)
.lastRow(0)
);
model.addRow(new Dummy("2", "1", "1"));
assertModel("1", "2");
events.assertEvents(
events.assertEvent()
.source(model)
.type(TableModelEvent.INSERT)
.column(TableModelEvent.ALL_COLUMNS)
.firstRow(1)
.lastRow(1)
);
}
@Test
public void checkClear() {
// Arrange
for (int i = 0; i < 5; i++) {
model.addRow(new Dummy("" + i, "" + i%2, "" + i%3));
}
assertModelRanges(range(0,5));
// Act
model.addTableModelListener(events);
model.clearData();
// Assert
assertModelRanges();
events.assertEvents(
events.assertEvent()
.source(model)
.type(TableModelEvent.UPDATE)
.column(TableModelEvent.ALL_COLUMNS)
.firstRow(0)
.lastRow(Integer.MAX_VALUE)
);
}
@Test
public void checkInsertRow() {
assertModel();
model.addRow(new Dummy("3", "1", "1"));
assertModel("3");
model.addTableModelListener(events);
model.insertRow(new Dummy("1", "1", "1"), 0);
assertModel("1", "3");
events.assertEvents(
events.assertEvent()
.source(model)
.type(TableModelEvent.INSERT)
.column(TableModelEvent.ALL_COLUMNS)
.firstRow(0)
.lastRow(0)
);
model.insertRow(new Dummy("2", "1", "1"), 1);
assertModel("1", "2", "3");
events.assertEvents(
events.assertEvent()
.source(model)
.type(TableModelEvent.INSERT)
.column(TableModelEvent.ALL_COLUMNS)
.firstRow(1)
.lastRow(1)
);
}
@Test
public void checkMoveRow_from_5_11_to_0() {
// Arrange
for (int i = 0; i < 20; i++) {
model.addRow(new Dummy("" + i, "" + i%2, "" + i%3));
}
assertModelRanges(range(0, 20));
// Act
model.addTableModelListener(events);
model.moveRow(5, 11, 0);
// Assert
assertModelRanges(range(5, 11), range(0, 5), range(11, 20));
events.assertEvents(
events.assertEvent()
.source(model)
.type(TableModelEvent.UPDATE)
.column(TableModelEvent.ALL_COLUMNS)
.firstRow(0)
.lastRow(Integer.MAX_VALUE)
);
}
@Test
public void checkMoveRow_from_0_6_to_0() {
// Arrange
for (int i = 0; i < 20; i++) {
model.addRow(new Dummy("" + i, "" + i%2, "" + i%3));
}
assertModelRanges(range(0, 20));
// Act
model.addTableModelListener(events);
model.moveRow(0, 6, 0);
// Assert
assertModelRanges(range(0, 20));
events.assertEvents(
events.assertEvent()
.source(model)
.type(TableModelEvent.UPDATE)
.column(TableModelEvent.ALL_COLUMNS)
.firstRow(0)
.lastRow(Integer.MAX_VALUE)
);
}
@Test
public void checkMoveRow_from_0_6_to_10() {
// Arrange
for (int i = 0; i < 20; i++) {
model.addRow(new Dummy("" + i, "" + i%2, "" + i%3));
}
assertModelRanges(range(0, 20));
// Act
model.addTableModelListener(events);
model.moveRow(0, 6, 10);
// Assert
assertModelRanges(range(6, 16), range(0, 6), range(16, 20));
events.assertEvents(
events.assertEvent()
.source(model)
.type(TableModelEvent.UPDATE)
.column(TableModelEvent.ALL_COLUMNS)
.firstRow(0)
.lastRow(Integer.MAX_VALUE)
);
}
private void assertModelRanges(IntStream... ranges) {
IntStream ints = IntStream.empty();
for (IntStream range : ranges) {
ints = IntStream.concat(ints, range);
}
assertModel(ints.mapToObj(i -> "" + i).toArray(n -> new String[n]));
}
private void assertModel(String... as) {
assertEquals("model row count", as.length, model.getRowCount());
for (int row = 0; row < as.length; row++) {
assertEquals(format("model[%d,0]", row), as[row], model.getValueAt(row, 0));
}
}
}

View File

@ -0,0 +1,304 @@
/*
* 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.jorphan.gui;
import static java.lang.String.format;
import static java.util.Arrays.asList;
import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;
import static org.hamcrest.CoreMatchers.allOf;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.not;
import static org.hamcrest.CoreMatchers.nullValue;
import static org.hamcrest.CoreMatchers.sameInstance;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThat;
import java.util.AbstractMap;
import java.util.AbstractMap.SimpleImmutableEntry;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map.Entry;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import javax.swing.RowSorter.SortKey;
import javax.swing.SortOrder;
import org.apache.jorphan.reflect.Functor;
import org.hamcrest.CoreMatchers;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ErrorCollector;
import org.junit.rules.ExpectedException;
public class ObjectTableSorterTest {
ObjectTableModel model;
ObjectTableSorter sorter;
@Rule
public ExpectedException expectedException = ExpectedException.none();
@Rule
public ErrorCollector errorCollector = new ErrorCollector();
@Before
public void createModelAndSorter() {
String[] headers = { "key", "value", "object" };
Functor[] readFunctors = { new Functor("getKey"), new Functor("getValue"), new Functor("getValue") };
Functor[] writeFunctors = { null, null, null };
Class<?>[] editorClasses = { String.class, Integer.class, Object.class };
model = new ObjectTableModel(headers, readFunctors, writeFunctors, editorClasses);
sorter = new ObjectTableSorter(model);
List<Entry<String,Integer>> data = asList(b2(), a3(), d4(), c1());
data.forEach(model::addRow);
}
@Test
public void noSorting() {
List<SimpleImmutableEntry<String, Integer>> expected = asList(b2(), a3(), d4(), c1());
assertRowOrderAndIndexes(expected);
}
@Test
public void sortKeyAscending() {
sorter.setSortKey(new SortKey(0, SortOrder.ASCENDING));
List<SimpleImmutableEntry<String, Integer>> expected = asList(a3(), b2(), c1(), d4());
assertRowOrderAndIndexes(expected);
}
@Test
public void sortKeyDescending() {
sorter.setSortKey(new SortKey(0, SortOrder.DESCENDING));
List<SimpleImmutableEntry<String, Integer>> expected = asList(d4(), c1(), b2(), a3());
assertRowOrderAndIndexes(expected);
}
@Test
public void sortValueAscending() {
sorter.setSortKey(new SortKey(1, SortOrder.ASCENDING));
List<SimpleImmutableEntry<String, Integer>> expected = asList(c1(), b2(), a3(), d4());
assertRowOrderAndIndexes(expected);
}
@Test
public void sortValueDescending() {
sorter.setSortKey(new SortKey(1, SortOrder.DESCENDING));
List<SimpleImmutableEntry<String, Integer>> expected = asList(d4(), a3(), b2(), c1());
assertRowOrderAndIndexes(expected);
}
@Test
public void fixLastRowWithAscendingKey() {
sorter.fixLastRow().setSortKey(new SortKey(0, SortOrder.ASCENDING));
List<SimpleImmutableEntry<String, Integer>> expected = asList(a3(), b2(), d4(), c1());
assertRowOrderAndIndexes(expected);
}
@Test
public void fixLastRowWithDescendingKey() {
sorter.fixLastRow().setSortKey(new SortKey(0, SortOrder.DESCENDING));
List<SimpleImmutableEntry<String, Integer>> expected = asList(d4(), b2(), a3(), c1());
assertRowOrderAndIndexes(expected);
}
@Test
public void fixLastRowWithAscendingValue() {
sorter.fixLastRow().setSortKey(new SortKey(1, SortOrder.ASCENDING));
List<SimpleImmutableEntry<String, Integer>> expected = asList(b2(), a3(), d4(), c1());
assertRowOrderAndIndexes(expected);
}
@Test
public void fixLastRowWithDescendingValue() {
sorter.fixLastRow().setSortKey(new SortKey(1, SortOrder.DESCENDING));
List<SimpleImmutableEntry<String, Integer>> expected = asList(d4(), a3(), b2(), c1());
assertRowOrderAndIndexes(expected);
}
@Test
public void customKeyOrder() {
HashMap<String, Integer> customKeyOrder = asList("a", "c", "b", "d").stream().reduce(new HashMap<String,Integer>(), (map,key) -> { map.put(key, map.size()); return map; }, (a,b) -> a);
Comparator<String> customKeyComparator = (a,b) -> customKeyOrder.get(a).compareTo(customKeyOrder.get(b));
sorter.setValueComparator(0, customKeyComparator).setSortKey(new SortKey(0, SortOrder.ASCENDING));
List<SimpleImmutableEntry<String, Integer>> expected = asList(a3(), c1(), b2(), d4());
assertRowOrderAndIndexes(expected);
}
@Test
public void getDefaultComparatorForNullClass() {
ObjectTableModel model = new ObjectTableModel(new String[] { "null" }, new Functor[] { null }, new Functor[] { null }, new Class<?>[] { null });
ObjectTableSorter sorter = new ObjectTableSorter(model);
assertThat(sorter.getValueComparator(0), is(nullValue()));
}
@Test
public void getDefaultComparatorForStringClass() {
ObjectTableModel model = new ObjectTableModel(new String[] { "string" }, new Functor[] { null }, new Functor[] { null }, new Class<?>[] { String.class });
ObjectTableSorter sorter = new ObjectTableSorter(model);
assertThat(sorter.getValueComparator(0), is(CoreMatchers.notNullValue()));
}
@Test
public void getDefaultComparatorForIntegerClass() {
ObjectTableModel model = new ObjectTableModel(new String[] { "integer" }, new Functor[] { null }, new Functor[] { null }, new Class<?>[] { Integer.class });
ObjectTableSorter sorter = new ObjectTableSorter(model);
assertThat(sorter.getValueComparator(0), is(CoreMatchers.notNullValue()));
}
@Test
public void getDefaultComparatorForObjectClass() {
ObjectTableModel model = new ObjectTableModel(new String[] { "integer" }, new Functor[] { null }, new Functor[] { null }, new Class<?>[] { Object.class });
ObjectTableSorter sorter = new ObjectTableSorter(model);
assertThat(sorter.getValueComparator(0), is(nullValue()));
}
@Test
public void toggleSortOrder_none() {
assertSame(emptyList(), sorter.getSortKeys());
}
@Test
public void toggleSortOrder_0() {
sorter.toggleSortOrder(0);
assertEquals(singletonList(new SortKey(0, SortOrder.ASCENDING)), sorter.getSortKeys());
}
@Test
public void toggleSortOrder_0_1() {
sorter.toggleSortOrder(0);
sorter.toggleSortOrder(1);
assertEquals(singletonList(new SortKey(1, SortOrder.ASCENDING)), sorter.getSortKeys());
}
@Test
public void toggleSortOrder_0_0() {
sorter.toggleSortOrder(0);
sorter.toggleSortOrder(0);
assertEquals(singletonList(new SortKey(0, SortOrder.DESCENDING)), sorter.getSortKeys());
}
@Test
public void toggleSortOrder_0_0_0() {
sorter.toggleSortOrder(0);
sorter.toggleSortOrder(0);
sorter.toggleSortOrder(0);
assertEquals(singletonList(new SortKey(0, SortOrder.ASCENDING)), sorter.getSortKeys());
}
@Test
public void toggleSortOrder_2() {
sorter.toggleSortOrder(2);
assertSame(emptyList(), sorter.getSortKeys());
}
@Test
public void toggleSortOrder_0_2() {
sorter.toggleSortOrder(0);
sorter.toggleSortOrder(2);
assertSame(emptyList(), sorter.getSortKeys());
}
@Test
public void setSortKeys_none() {
sorter.setSortKeys(new ArrayList<>());
assertSame(Collections.emptyList(), sorter.getSortKeys());
}
@Test
public void setSortKeys_withSortedThenUnsorted() {
sorter.setSortKeys(singletonList(new SortKey(0, SortOrder.ASCENDING)));
sorter.setSortKeys(new ArrayList<>());
assertSame(Collections.emptyList(), sorter.getSortKeys());
}
@Test
public void setSortKeys_single() {
List<SortKey> keys = singletonList(new SortKey(0, SortOrder.ASCENDING));
sorter.setSortKeys(keys);
assertThat(sorter.getSortKeys(), allOf( is(not(sameInstance(keys))), is(equalTo(keys)) ));
}
@Test
public void setSortKeys_many() {
expectedException.expect(IllegalArgumentException.class);
sorter.setSortKeys(asList(new SortKey(0, SortOrder.ASCENDING), new SortKey(1, SortOrder.ASCENDING)));
}
@Test
public void setSortKeys_invalidColumn() {
expectedException.expect(IllegalArgumentException.class);
sorter.setSortKeys(Collections.singletonList(new SortKey(2, SortOrder.ASCENDING)));
}
@SuppressWarnings("unchecked")
protected List<Entry<String,Integer>> actual() {
return IntStream
.range(0, sorter.getViewRowCount())
.map(sorter::convertRowIndexToModel)
.mapToObj(modelIndex -> (Entry<String,Integer>) sorter.getModel().getObjectListAsList().get(modelIndex))
.collect(Collectors.toList())
;
}
protected SimpleImmutableEntry<String, Integer> d4() {
return new AbstractMap.SimpleImmutableEntry<>("d", 4);
}
protected SimpleImmutableEntry<String, Integer> c1() {
return new AbstractMap.SimpleImmutableEntry<>("c", 1);
}
protected SimpleImmutableEntry<String, Integer> b2() {
return new AbstractMap.SimpleImmutableEntry<>("b", 2);
}
protected SimpleImmutableEntry<String, Integer> a3() {
return new AbstractMap.SimpleImmutableEntry<>("a", 3);
}
protected void assertRowOrderAndIndexes(List<SimpleImmutableEntry<String, Integer>> expected) {
assertEquals(expected, actual());
assertRowIndexes();
}
protected void assertRowIndexes() {
IntStream
.range(0, sorter.getViewRowCount())
.forEach(viewIndex -> {
int modelIndex = sorter.convertRowIndexToModel(viewIndex);
errorCollector.checkThat(format("view(%d) model(%d)", viewIndex, modelIndex),
sorter.convertRowIndexToView(modelIndex),
CoreMatchers.equalTo(viewIndex));
});
}
}

View File

@ -0,0 +1,154 @@
/*
* 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.jorphan.gui;
import static java.lang.String.format;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import java.util.ArrayList;
import java.util.Deque;
import java.util.LinkedList;
import java.util.List;
import java.util.function.ObjIntConsumer;
import java.util.function.ToIntFunction;
import javax.swing.event.TableModelEvent;
import javax.swing.event.TableModelListener;
/**
* Listener implementation that stores {@link TableModelEvent} and can make assertions against them.
*/
public class TableModelEventBacker implements TableModelListener {
/**
* Makes assertions for a single {@link TableModelEvent}.
*/
public class EventAssertion {
private List<ObjIntConsumer<TableModelEvent>> assertions = new ArrayList<>();
/**
* Adds an assertion first args is table model event, second one is event index.
* @return <code>this</code>
*/
public EventAssertion add(ObjIntConsumer<TableModelEvent> assertion) {
assertions.add(assertion);
return this;
}
/**
* Adds assertion based on a {@link ToIntFunction to-int} transformation (examples: <code>TableModelEvent::getType</code>).
* @param name Label for assertion reason
* @param expected Expected value.
* @param f {@link ToIntFunction to-int} transformation (examples: <code>TableModelEvent::getType</code>).
* @return <code>this</code>
*/
public EventAssertion addInt(String name, int expected, ToIntFunction<TableModelEvent> f) {
return add((e,i) -> assertEquals(format("%s[%d]", name, i), expected, f.applyAsInt(e)));
}
/**
* Adds {@link TableModelEvent#getSource()} assertion.
* @return <code>this</code>
*/
public EventAssertion source(Object expected) {
return add((e,i) -> assertSame(format("source[%d]",i), expected, e.getSource()));
}
/**
* Adds {@link TableModelEvent#getType()} assertion.
* @return <code>this</code>
*/
public EventAssertion type(int expected) {
return addInt("type", expected, TableModelEvent::getType);
}
/**
* Adds {@link TableModelEvent#getColumn()} assertion.
* @return <code>this</code>
*/
public EventAssertion column(int expected) {
return addInt("column", expected, TableModelEvent::getColumn);
}
/**
* Adds {@link TableModelEvent#getFirstRow()} assertion.
* @return <code>this</code>
*/
public EventAssertion firstRow(int expected) {
return addInt("firstRow", expected, TableModelEvent::getFirstRow);
}
/**
* Adds {@link TableModelEvent#getLastRow()} assertion.
* @return <code>this</code>
*/
public EventAssertion lastRow(int expected) {
return addInt("lastRow", expected, TableModelEvent::getLastRow);
}
/**
* Check assertion against provided value.
* @param event Event to check
* @param index Index.
*/
protected void assertEvent(TableModelEvent event, int index) {
assertions.forEach(a -> a.accept(event, index));
}
}
private Deque<TableModelEvent> events = new LinkedList<>();
/**
* Stores event.
*/
@Override
public void tableChanged(TableModelEvent e) {
events.add(e);
}
public Deque<TableModelEvent> getEvents() {
return events;
}
/**
* Creates a new event assertion.
* @see #assertEvents(EventAssertion...)
*/
public EventAssertion assertEvent() {
return new EventAssertion();
}
/**
* Checks each event assertion against each backed event in order. Event storage is cleared after it.
*/
public void assertEvents(EventAssertion... assertions) {
try {
assertEquals("event count", assertions.length, events.size());
int i = 0;
for (TableModelEvent event : events) {
assertions[i].assertEvent(event, i++);
}
} finally {
events.clear();
}
}
}

View File

@ -124,6 +124,7 @@ Fill in some detail.
<ul>
<li><bug>60144</bug>View Results Tree : Add a more up to date Browser Renderer to replace old Render</li>
<li><bug>60542</bug>View Results Tree : Allow Upper Panel to be collapsed. Contributed by Ubik Load Pack (support at ubikloadpack.com)</li>
<li><bug>52962</bug>Allow sorting by columns for View Results in Table, Summary Report, Aggregate Report and Aggregate Graph. Based on a contribution by Logan Mauzaize (logan.mauzaize at gmail.com) and Maxime Chassagneux (maxime.chassagneux@gmail.com).</li>
</ul>
<h3>Timers, Assertions, Config, Pre- &amp; Post-Processors</h3>
@ -147,7 +148,7 @@ Fill in some detail.
<h3>General</h3>
<ul>
<li><bug>54525</bug>Search Feature : Enhance it with ability to replace</li>
<li><bug>60530</bug>Add API to create JMeter threads while test is running. Based on a contribution by Logan Mauzaize and Maxime Chassagneux</li>
<li><bug>60530</bug>Add API to create JMeter threads while test is running. Based on a contribution by Logan Mauzaize (logan.mauzaize at gmail.com) and Maxime Chassagneux (maxime.chassagneux@gmail.com).</li>
</ul>
<ch_section>Non-functional changes</ch_section>
@ -216,8 +217,8 @@ Fill in some detail.
<li>(gavin at 16degrees.com.au)</li>
<li>Thomas Schapitz (ts-nospam12 at online.de)</li>
<li>Murdecai777 (https://github.com/Murdecai777)</li>
<li>Logan Mauzaize (https://github.com/loganmzz)</li>
<li>Maxime Chassagneux (https://github.com/max3163)</li>
<li>Logan Mauzaize (logan.mauzaize at gmail.com)</li>
<li>Maxime Chassagneux (maxime.chassagneux@gmail.com)</li>
<li>忻隆 (298015902 at qq.com)</li>
<li><a href="http://ubikloadpack.com">Ubik Load Pack</a></li>
</ul>