blob: 86b3175eb8911bcc4e6fd5e5e506c59454adb30d [file] [log] [blame]
/*
* 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.solr.search;
import java.lang.invoke.MethodHandles;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.apache.lucene.util.TestUtil;
import org.apache.solr.client.solrj.SolrQuery;
import org.apache.solr.client.solrj.request.CollectionAdminRequest;
import org.apache.solr.client.solrj.request.UpdateRequest;
import org.apache.solr.client.solrj.response.QueryResponse;
import org.apache.solr.client.solrj.response.RangeFacet;
import org.apache.solr.cloud.SolrCloudTestCase;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.params.CoreAdminParams;
import org.apache.solr.common.util.NamedList;
import org.apache.solr.schema.CurrencyFieldTypeTest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.junit.BeforeClass;
import org.junit.Test;
public class CurrencyRangeFacetCloudTest extends SolrCloudTestCase {
private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
private static final String COLLECTION = MethodHandles.lookup().lookupClass().getName();
private static final String CONF = COLLECTION + "_configSet";
private static String FIELD = null; // randomized
private static final List<String> STR_VALS = Arrays.asList("x0", "x1", "x2");
// NOTE: in our test conversions EUR uses an asynetric echange rate
// these are the equivalent values relative to: USD EUR GBP
private static final List<String> VALUES = Arrays.asList("10.00,USD", // 10.00,USD 25.00,EUR 5.00,GBP
"15.00,EUR", // 7.50,USD 15.00,EUR 7.50,GBP
"6.00,GBP", // 12.00,USD 12.00,EUR 6.00,GBP
"7.00,EUR", // 3.50,USD 7.00,EUR 3.50,GBP
"2,GBP"); // 4.00,USD 4.00,EUR 2.00,GBP
private static final int NUM_DOCS = STR_VALS.size() * VALUES.size();
@BeforeClass
public static void setupCluster() throws Exception {
CurrencyFieldTypeTest.assumeCurrencySupport("USD", "EUR", "MXN", "GBP", "JPY", "NOK");
FIELD = usually() ? "amount_CFT" : "amount";
final int numShards = TestUtil.nextInt(random(),1,5);
final int numReplicas = 1;
final int maxShardsPerNode = 1;
final int nodeCount = numShards * numReplicas;
configureCluster(nodeCount)
.addConfig(CONF, Paths.get(TEST_HOME(), "collection1", "conf"))
.configure();
assertEquals(0, (CollectionAdminRequest.createCollection(COLLECTION, CONF, numShards, numReplicas)
.setPerReplicaState(SolrCloudTestCase.USE_PER_REPLICA_STATE)
.setMaxShardsPerNode(maxShardsPerNode)
.setProperties(Collections.singletonMap(CoreAdminParams.CONFIG, "solrconfig-minimal.xml"))
.process(cluster.getSolrClient())).getStatus());
cluster.getSolrClient().setDefaultCollection(COLLECTION);
for (int id = 0; id < NUM_DOCS; id++) { // we're indexing each Currency value in 3 docs, each with a diff 'x_s' field value
// use modulo to pick the values, so we don't add the docs in strict order of either VALUES of STR_VALS
// (that way if we want ot filter by id later, it's an independent variable)
final String x = STR_VALS.get(id % STR_VALS.size());
final String val = VALUES.get(id % VALUES.size());
assertEquals(0, (new UpdateRequest().add(sdoc("id", "" + id,
"x_s", x,
FIELD, val))
).process(cluster.getSolrClient()).getStatus());
}
assertEquals(0, cluster.getSolrClient().commit().getStatus());
}
@SuppressWarnings({"rawtypes"})
public void testSimpleRangeFacetsOfSymetricRates() throws Exception {
for (boolean use_mincount : Arrays.asList(true, false)) {
// exchange rates relative to USD...
//
// for all of these permutations, the numDocs in each bucket that we get back should be the same
// (regardless of the any asymetric echanges ranges, or the currency used for the 'gap') because the
// start & end are always in USD.
//
// NOTE:
// - 0,1,2 are the *input* start,gap,end
// - 3,4,5 are the *normalized* start,gap,end expected in the response
for (List<String> args : Arrays.asList(// default currency is USD
Arrays.asList("4", "1.00", "11.0",
"4.00,USD", "1.00,USD", "11.00,USD"),
// explicit USD
Arrays.asList("4,USD", "1,USD", "11,USD",
"4.00,USD", "1.00,USD", "11.00,USD"),
// Gap can be in diff currency (but start/end must currently match)
Arrays.asList("4.00,USD", "000.50,GBP", "11,USD",
"4.00,USD", ".50,GBP", "11.00,USD"),
Arrays.asList("4.00,USD", "2,EUR", "11,USD",
"4.00,USD", "2.00,EUR", "11.00,USD"))) {
assertEquals(6, args.size()); // sanity check
// first let's check facet.range
SolrQuery solrQuery = new SolrQuery("q", "*:*", "rows", "0", "facet", "true", "facet.range", FIELD,
"facet.mincount", (use_mincount ? "3" : "0"),
"f." + FIELD + ".facet.range.start", args.get(0),
"f." + FIELD + ".facet.range.gap", args.get(1),
"f." + FIELD + ".facet.range.end", args.get(2),
"f." + FIELD + ".facet.range.other", "all");
QueryResponse rsp = cluster.getSolrClient().query(solrQuery);
try {
assertEquals(NUM_DOCS, rsp.getResults().getNumFound());
final String start = args.get(3);
final String gap = args.get(4);
final String end = args.get(5);
final List<RangeFacet> range_facets = rsp.getFacetRanges();
assertEquals(1, range_facets.size());
final RangeFacet result = range_facets.get(0);
assertEquals(FIELD, result.getName());
assertEquals(start, result.getStart());
assertEquals(gap, result.getGap());
assertEquals(end, result.getEnd());
assertEquals(3, result.getBefore());
assertEquals(3, result.getAfter());
assertEquals(9, result.getBetween());
@SuppressWarnings({"unchecked"})
List<RangeFacet.Count> counts = result.getCounts();
if (use_mincount) {
assertEquals(3, counts.size());
for (int i = 0; i < 3; i++) {
RangeFacet.Count bucket = counts.get(i);
assertEquals((4 + (i * 3)) + ".00,USD", bucket.getValue());
assertEquals("bucket #" + i, 3, bucket.getCount());
}
} else {
assertEquals(7, counts.size());
for (int i = 0; i < 7; i++) {
RangeFacet.Count bucket = counts.get(i);
assertEquals((4 + i) + ".00,USD", bucket.getValue());
assertEquals("bucket #" + i, (i == 0 || i == 3 || i == 6) ? 3 : 0, bucket.getCount());
}
}
} catch (AssertionError|RuntimeException ae) {
throw new AssertionError(solrQuery.toString() + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae);
}
// same basic logic, w/json.facet
solrQuery = new SolrQuery("q", "*:*", "rows", "0", "json.facet",
"{ foo:{ type:range, field:"+FIELD+", mincount:"+(use_mincount ? 3 : 0)+", " +
" start:'"+args.get(0)+"', gap:'"+args.get(1)+"', end:'"+args.get(2)+"', other:all}}");
rsp = cluster.getSolrClient().query(solrQuery);
try {
assertEquals(NUM_DOCS, rsp.getResults().getNumFound());
@SuppressWarnings({"unchecked"})
final NamedList<Object> foo = ((NamedList<NamedList<Object>>)rsp.getResponse().get("facets")).get("foo");
assertEqualsHACK("before", 3L, ((NamedList)foo.get("before")).get("count"));
assertEqualsHACK("after", 3L, ((NamedList)foo.get("after")).get("count"));
assertEqualsHACK("between", 9L, ((NamedList)foo.get("between")).get("count"));
@SuppressWarnings({"unchecked"})
final List<NamedList> buckets = (List<NamedList>) foo.get("buckets");
if (use_mincount) {
assertEquals(3, buckets.size());
for (int i = 0; i < 3; i++) {
NamedList bucket = buckets.get(i);
assertEquals((4 + (3 * i)) + ".00,USD", bucket.get("val"));
assertEqualsHACK("bucket #" + i, 3L, bucket.get("count"));
}
} else {
assertEquals(7, buckets.size());
for (int i = 0; i < 7; i++) {
NamedList bucket = buckets.get(i);
assertEquals((4 + i) + ".00,USD", bucket.get("val"));
assertEqualsHACK("bucket #" + i, (i == 0 || i == 3 || i == 6) ? 3L : 0L, bucket.get("count"));
}
}
} catch (AssertionError|RuntimeException ae) {
throw new AssertionError(solrQuery.toString() + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae);
}
}
}
}
public void testFacetRangeOfAsymetricRates() throws Exception {
// facet.range: exchange rates relative to EUR...
//
// because of the asymetric echange rate, the counts for these buckets will be different
// then if we just converted the EUR values to USD
for (boolean use_mincount : Arrays.asList(true, false)) {
final SolrQuery solrQuery = new SolrQuery("q", "*:*", "rows", "0", "facet", "true", "facet.range", FIELD,
"facet.mincount", (use_mincount ? "3" : "0"),
"f." + FIELD + ".facet.range.start", "8,EUR",
"f." + FIELD + ".facet.range.gap", "2,EUR",
"f." + FIELD + ".facet.range.end", "22,EUR",
"f." + FIELD + ".facet.range.other", "all");
final QueryResponse rsp = cluster.getSolrClient().query(solrQuery);
try {
assertEquals(NUM_DOCS, rsp.getResults().getNumFound());
@SuppressWarnings({"rawtypes"})
final List<RangeFacet> range_facets = rsp.getFacetRanges();
assertEquals(1, range_facets.size());
@SuppressWarnings({"rawtypes"})
final RangeFacet result = range_facets.get(0);
assertEquals(FIELD, result.getName());
assertEquals("8.00,EUR", result.getStart());
assertEquals("2.00,EUR", result.getGap());
assertEquals("22.00,EUR", result.getEnd());
assertEquals(6, result.getBefore());
assertEquals(3, result.getAfter());
assertEquals(6, result.getBetween());
@SuppressWarnings({"unchecked"})
List<RangeFacet.Count> counts = result.getCounts();
if (use_mincount) {
assertEquals(2, counts.size());
for (int i = 0; i < 2; i++) {
RangeFacet.Count bucket = counts.get(i);
assertEquals((12 + (i * 2)) + ".00,EUR", bucket.getValue());
assertEquals("bucket #" + i, 3, bucket.getCount());
}
} else {
assertEquals(7, counts.size());
for (int i = 0; i < 7; i++) {
RangeFacet.Count bucket = counts.get(i);
assertEquals((8 + (i * 2)) + ".00,EUR", bucket.getValue());
assertEquals("bucket #" + i, (i == 2 || i == 3) ? 3 : 0, bucket.getCount());
}
}
} catch (AssertionError|RuntimeException ae) {
throw new AssertionError(solrQuery.toString() + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae);
}
}
}
public void testJsonFacetRangeOfAsymetricRates() throws Exception {
// json.facet: exchange rates relative to EUR (same as testFacetRangeOfAsymetricRates)
//
// because of the asymetric echange rate, the counts for these buckets will be different
// then if we just converted the EUR values to USD
for (boolean use_mincount : Arrays.asList(true, false)) {
final SolrQuery solrQuery = new SolrQuery("q", "*:*", "rows", "0", "json.facet",
"{ foo:{ type:range, field:"+FIELD+", start:'8,EUR', " +
" mincount:"+(use_mincount ? 3 : 0)+", " +
" gap:'2,EUR', end:'22,EUR', other:all}}");
final QueryResponse rsp = cluster.getSolrClient().query(solrQuery);
try {
assertEquals(NUM_DOCS, rsp.getResults().getNumFound());
@SuppressWarnings({"unchecked"})
final NamedList<Object> foo = ((NamedList<NamedList<Object>>)rsp.getResponse().get("facets")).get("foo");
assertEqualsHACK("before", 6L, ((NamedList)foo.get("before")).get("count"));
assertEqualsHACK("after", 3L, ((NamedList)foo.get("after")).get("count"));
assertEqualsHACK("between", 6L, ((NamedList)foo.get("between")).get("count"));
@SuppressWarnings({"unchecked", "rawtypes"})
final List<NamedList> buckets = (List<NamedList>) foo.get("buckets");
if (use_mincount) {
assertEquals(2, buckets.size());
for (int i = 0; i < 2; i++) {
@SuppressWarnings({"rawtypes"})
NamedList bucket = buckets.get(i);
assertEquals((12 + (i * 2)) + ".00,EUR", bucket.get("val"));
assertEqualsHACK("bucket #" + i, 3L, bucket.get("count"));
}
} else {
assertEquals(7, buckets.size());
for (int i = 0; i < 7; i++) {
@SuppressWarnings({"rawtypes"})
NamedList bucket = buckets.get(i);
assertEquals((8 + (i * 2)) + ".00,EUR", bucket.get("val"));
assertEqualsHACK("bucket #" + i, (i == 2 || i == 3) ? 3L : 0L, bucket.get("count"));
}
}
} catch (AssertionError|RuntimeException ae) {
throw new AssertionError(solrQuery.toString() + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae);
}
}
}
public void testFacetRangeCleanErrorOnMissmatchCurrency() {
final String expected = "Cannot compare CurrencyValues when their currencies are not equal";
ignoreException(expected);
// test to check clean error when start/end have diff currency (facet.range)
final SolrQuery solrQuery = new SolrQuery("q", "*:*", "rows", "0", "facet", "true", "facet.range", FIELD,
"f." + FIELD + ".facet.range.start", "0,EUR",
"f." + FIELD + ".facet.range.gap", "10,EUR",
"f." + FIELD + ".facet.range.end", "100,USD");
final SolrException ex = expectThrows(SolrException.class, () -> {
final QueryResponse rsp = cluster.getSolrClient().query(solrQuery);
});
assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code());
assertTrue(ex.getMessage(), ex.getMessage().contains(expected));
}
public void testJsonFacetCleanErrorOnMissmatchCurrency() {
final String expected = "Cannot compare CurrencyValues when their currencies are not equal";
ignoreException(expected);
// test to check clean error when start/end have diff currency (json.facet)
final SolrQuery solrQuery = new SolrQuery("q", "*:*", "json.facet",
"{ x:{ type:range, field:"+FIELD+", " +
" start:'0,EUR', gap:'10,EUR', end:'100,USD' } }");
final SolrException ex = expectThrows(SolrException.class, () -> {
final QueryResponse rsp = cluster.getSolrClient().query(solrQuery);
});
assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code());
assertTrue(ex.getMessage(), ex.getMessage().contains(expected));
}
@Test
public void testJsonRangeFacetWithSubFacet() throws Exception {
// range facet, with terms facet nested under using limit=2 w/overrequest disabled
// filter out the first 5 docs (by id) which should ensure that regardless of sharding:
// - x2 being the top term for the 1st range bucket
// - x0 being the top term for the 2nd range bucket
// - the 2nd term in each bucket may vary based on shard/doc placement, but the count will always be '1'
// ...and in many cases (based on the shard/doc placement) this will require refinement to backfill the top terms
final String filter = "id_i1:["+VALUES.size()+" TO *]";
// the *facet* results should be the same regardless of wether we filter via fq, or using a domain filter on the top facet
for (boolean use_domain : Arrays.asList(true, false)) {
final String domain = use_domain ? "domain: { filter:'" + filter + "'}," : "";
// both of these options should produce same results since hardened:false is default
final String end = random().nextBoolean() ? "end:'20,EUR'" : "end:'15,EUR'";
final SolrQuery solrQuery = new SolrQuery("q", (use_domain ? "*:*" : filter),
"rows", "0", "json.facet",
"{ bar:{ type:range, field:"+FIELD+", " + domain +
" start:'0,EUR', gap:'10,EUR', "+end+", other:all " +
" facet: { foo:{ type:terms, field:x_s, " +
" refine:true, limit:2, overrequest:0" +
" } } } }");
final QueryResponse rsp = cluster.getSolrClient().query(solrQuery);
try {
// this top level result count sanity check that should vary based on how we are filtering our facets...
assertEquals(use_domain ? 15 : 10, rsp.getResults().getNumFound());
@SuppressWarnings({"unchecked"})
final NamedList<Object> bar = ((NamedList<NamedList<Object>>)rsp.getResponse().get("facets")).get("bar");
@SuppressWarnings({"unchecked"})
final List<NamedList<Object>> bar_buckets = (List<NamedList<Object>>) bar.get("buckets");
@SuppressWarnings({"unchecked"})
final NamedList<Object> before = (NamedList<Object>) bar.get("before");
@SuppressWarnings({"unchecked"})
final NamedList<Object> between = (NamedList<Object>) bar.get("between");
@SuppressWarnings({"unchecked"})
final NamedList<Object> after = (NamedList<Object>) bar.get("after");
// sanity check our high level expectations...
assertEquals("bar num buckets", 2, bar_buckets.size());
assertEqualsHACK("before count", 0L, before.get("count"));
assertEqualsHACK("between count", 8L, between.get("count"));
assertEqualsHACK("after count", 2L, after.get("count"));
// drill into the various buckets...
// before should have no subfacets since it's empty...
assertNull("before has foo???", before.get("foo"));
// our 2 range buckets & their sub facets...
for (int i = 0; i < 2; i++) {
final NamedList<Object> bucket = bar_buckets.get(i);
assertEquals((i * 10) + ".00,EUR", bucket.get("val"));
assertEqualsHACK("bucket #" + i, 4L, bucket.get("count"));
@SuppressWarnings({"unchecked"})
final List<NamedList<Object>> foo_buckets = ((NamedList<List<NamedList<Object>>>)bucket.get("foo")).get("buckets");
assertEquals("bucket #" + i + " foo num buckets", 2, foo_buckets.size());
assertEquals("bucket #" + i + " foo top term", (0==i ? "x2" : "x0"), foo_buckets.get(0).get("val"));
assertEqualsHACK("bucket #" + i + " foo top count", 2, foo_buckets.get(0).get("count"));
// NOTE: we can't make any assertions about the 2nd val..
// our limit + randomized sharding could result in either remaining term being picked.
// but for eiter term, the count should be exactly the same...
assertEqualsHACK("bucket #" + i + " foo 2nd count", 1, foo_buckets.get(1).get("count"));
}
{ // between...
@SuppressWarnings({"unchecked"})
final List<NamedList<Object>> buckets = ((NamedList<List<NamedList<Object>>>)between.get("foo")).get("buckets");
assertEquals("between num buckets", 2, buckets.size());
// the counts should both be 3, and the term order should break the tie...
assertEquals("between bucket top", "x0", buckets.get(0).get("val"));
assertEqualsHACK("between bucket top count", 3L, buckets.get(0).get("count"));
assertEquals("between bucket 2nd", "x2", buckets.get(1).get("val"));
assertEqualsHACK("between bucket 2nd count", 3L, buckets.get(1).get("count"));
}
{ // after...
@SuppressWarnings({"unchecked"})
final List<NamedList<Object>> buckets = ((NamedList<List<NamedList<Object>>>)after.get("foo")).get("buckets");
assertEquals("after num buckets", 2, buckets.size());
// the counts should both be 1, and the term order should break the tie...
assertEquals("after bucket top", "x1", buckets.get(0).get("val"));
assertEqualsHACK("after bucket top count", 1L, buckets.get(0).get("count"));
assertEquals("after bucket 2nd", "x2", buckets.get(1).get("val"));
assertEqualsHACK("after bucket 2nd count", 1L, buckets.get(1).get("count"));
}
} catch (AssertionError|RuntimeException ae) {
throw new AssertionError(solrQuery.toString() + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae);
}
}
}
@Test
public void testJsonRangeFacetAsSubFacet() throws Exception {
// limit=1, overrequest=1, with refinement enabled
// filter out the first 5 docs (by id), which should ensure that 'x2' is the top bucket overall...
// ...except in some rare sharding cases where it doesn't make it into the top 2 terms.
//
// So the filter also explicitly accepts all 'x2' docs -- ensuring we have enough matches containing that term for it
// to be enough of a candidate in phase#1, but for many shard arrangements it won't be returned by all shards resulting
// in refinement being neccessary to get the x_s:x2 sub-shard ranges from shard(s) where x_s:x2 is only tied for the
// (shard local) top term count and would lose the (index order) tie breaker with x_s:x0 or x_s:x1
final String filter = "id_i1:["+VALUES.size()+" TO *] OR x_s:x2";
// the *facet* results should be the same regardless of wether we filter via fq, or using a domain filter on the top facet
for (boolean use_domain : Arrays.asList(true, false)) {
final String domain = use_domain ? "domain: { filter:'" + filter + "'}," : "";
final SolrQuery solrQuery = new SolrQuery("q", (use_domain ? "*:*" : filter),
"rows", "0", "json.facet",
"{ foo:{ type:terms, field:x_s, refine:true, limit:1, overrequest:1, " + domain +
" facet: { bar:{ type:range, field:"+FIELD+", other:all, " +
" start:'8,EUR', gap:'2,EUR', end:'22,EUR' }} } }");
final QueryResponse rsp = cluster.getSolrClient().query(solrQuery);
try {
// this top level result count sanity check that should vary based on how we are filtering our facets...
assertEquals(use_domain ? 15 : 11, rsp.getResults().getNumFound());
@SuppressWarnings({"unchecked"})
final NamedList<Object> foo = ((NamedList<NamedList<Object>>)rsp.getResponse().get("facets")).get("foo");
// sanity check...
// because of the facet limit, foo should only have 1 bucket
// because of the fq, the val should be "x2" and the count=5
@SuppressWarnings({"unchecked"})
final List<NamedList<Object>> foo_buckets = (List<NamedList<Object>>) foo.get("buckets");
assertEquals(1, foo_buckets.size());
assertEquals("x2", foo_buckets.get(0).get("val"));
assertEqualsHACK("foo bucket count", 5L, foo_buckets.get(0).get("count"));
@SuppressWarnings({"unchecked"})
final NamedList<Object> bar = (NamedList<Object>)foo_buckets.get(0).get("bar");
// these are the 'x2' specific counts, based on our fq...
assertEqualsHACK("before", 2L, ((NamedList)bar.get("before")).get("count"));
assertEqualsHACK("after", 1L, ((NamedList)bar.get("after")).get("count"));
assertEqualsHACK("between", 2L, ((NamedList)bar.get("between")).get("count"));
@SuppressWarnings({"unchecked", "rawtypes"})
final List<NamedList> buckets = (List<NamedList>) bar.get("buckets");
assertEquals(7, buckets.size());
for (int i = 0; i < 7; i++) {
@SuppressWarnings({"rawtypes"})
NamedList bucket = buckets.get(i);
assertEquals((8 + (i * 2)) + ".00,EUR", bucket.get("val"));
// 12,EUR & 15,EUR are the 2 values that align with x2 docs
assertEqualsHACK("bucket #" + i, (i == 2 || i == 3) ? 1L : 0L, bucket.get("count"));
}
} catch (AssertionError|RuntimeException ae) {
throw new AssertionError(solrQuery.toString() + " -> " + rsp.toString() + " ===> " + ae.getMessage(), ae);
}
}
}
/**
* HACK to work around SOLR-11775.
* Asserts that the 'actual' argument is a (non-null) Number, then compares it's 'longValue' to the 'expected' argument
*/
private static void assertEqualsHACK(String msg, long expected, Object actual) {
assertNotNull(msg, actual);
assertTrue(msg + " ... NOT A NUMBER: " + actual.getClass(), Number.class.isInstance(actual));
assertEquals(msg, expected, ((Number)actual).longValue());
}
}