Optimize queries to overpass

This commit is contained in:
pietervdvn 2022-03-13 01:27:19 +01:00
parent fbcb72df7a
commit 9008e333ac
15 changed files with 787 additions and 18 deletions

View file

@ -25,7 +25,12 @@ export class Overpass {
includeMeta = true) {
this._timeout = timeout;
this._interpreterUrl = interpreterUrl;
this._filter = filter
const optimized = filter.optimize()
if(optimized === true || optimized === false){
throw "Invalid filter: optimizes to true of false"
}
this._filter = optimized
console.log("Overpass filter is",this._filter)
this._extraScripts = extraScripts;
this._includeMeta = includeMeta;
this._relationTracker = relationTracker

View file

@ -1,4 +1,6 @@
import {TagsFilter} from "./TagsFilter";
import {Or} from "./Or";
import {TagUtils} from "./TagUtils";
export class And extends TagsFilter {
public and: TagsFilter[]
@ -110,6 +112,10 @@ export class And extends TagsFilter {
return [].concat(...this.and.map(subkeys => subkeys.usedKeys()));
}
usedTags(): { key: string; value: string }[] {
return [].concat(...this.and.map(subkeys => subkeys.usedTags()));
}
asChange(properties: any): { k: string; v: string }[] {
const result = []
for (const tagsFilter of this.and) {
@ -123,4 +129,89 @@ export class And extends TagsFilter {
and: this.and.map(a => a.AsJson())
}
}
optimize(): TagsFilter | boolean {
if(this.and.length === 0){
return true
}
const optimized = this.and.map(t => t.optimize())
const newAnds : TagsFilter[] = []
let containedOrs : Or[] = []
for (const tf of optimized) {
if(tf === false){
return false
}
if(tf === true){
continue
}
if(tf instanceof And){
newAnds.push(...tf.and)
}else if(tf instanceof Or){
containedOrs.push(tf)
} else {
newAnds.push(tf)
}
}
containedOrs = containedOrs.filter(ca => {
for (const element of ca.or) {
if(optimized.some(opt => typeof opt !== "boolean" && element.isEquivalent(opt) )){
// At least one part of the 'OR' is matched by the outer or, so this means that this OR isn't needed at all
// XY & (XY | AB) === XY
return false
}
}
return true;
})
// Extract common keys from the OR
if(containedOrs.length === 1){
newAnds.push(containedOrs[0])
}
if(containedOrs.length > 1){
let commonValues : TagsFilter [] = containedOrs[0].or
for (let i = 1; i < containedOrs.length && commonValues.length > 0; i++){
const containedOr = containedOrs[i];
commonValues = commonValues.filter(cv => containedOr.or.some(candidate => candidate.isEquivalent(cv)))
}
if(commonValues.length === 0){
newAnds.push(...containedOrs)
}else{
const newOrs: TagsFilter[] = []
for (const containedOr of containedOrs) {
const elements = containedOr.or
.filter(candidate => !commonValues.some(cv => cv.isEquivalent(candidate)))
const or = new Or(elements).optimize()
if(or === true){
// neutral element
continue
}
if(or === false){
return false
}
newOrs.push(or)
}
commonValues.push(new And(newOrs))
const result = new Or(commonValues).optimize()
if(result === false){
return false
}else if(result === true){
// neutral element: skip
}else{
newAnds.push(result)
}
}
}
if(newAnds.length === 1){
return newAnds[0]
}
TagUtils.sortFilters(newAnds, true)
return new And(newAnds)
}
}

View file

@ -39,8 +39,15 @@ export default class ComparingTag implements TagsFilter {
return [this._key];
}
usedTags(): { key: string; value: string }[] {
return [];
}
AsJson() {
return this.asHumanString(false, false, {})
}
optimize(): TagsFilter | boolean {
return this;
}
}

View file

@ -1,4 +1,6 @@
import {TagsFilter} from "./TagsFilter";
import {TagUtils} from "./TagUtils";
import {And} from "./And";
export class Or extends TagsFilter {
@ -58,6 +60,10 @@ export class Or extends TagsFilter {
return [].concat(...this.or.map(subkeys => subkeys.usedKeys()));
}
usedTags(): { key: string; value: string }[] {
return [].concat(...this.or.map(subkeys => subkeys.usedTags()));
}
asChange(properties: any): { k: string; v: string }[] {
const result = []
for (const tagsFilter of this.or) {
@ -71,6 +77,83 @@ export class Or extends TagsFilter {
or: this.or.map(o => o.AsJson())
}
}
optimize(): TagsFilter | boolean {
if(this.or.length === 0){
return false;
}
const optimized = this.or.map(t => t.optimize())
const newOrs : TagsFilter[] = []
let containedAnds : And[] = []
for (const tf of optimized) {
if(tf === true){
return true
}
if(tf === false){
continue
}
if(tf instanceof Or){
newOrs.push(...tf.or)
}else if(tf instanceof And){
containedAnds.push(tf)
} else {
newOrs.push(tf)
}
}
containedAnds = containedAnds.filter(ca => {
for (const element of ca.and) {
if(optimized.some(opt => typeof opt !== "boolean" && element.isEquivalent(opt) )){
// At least one part of the 'AND' is matched by the outer or, so this means that this OR isn't needed at all
// XY | (XY & AB) === XY
return false
}
}
return true;
})
// Extract common keys from the ANDS
if(containedAnds.length === 1){
newOrs.push(containedAnds[0])
} else if(containedAnds.length > 1){
let commonValues : TagsFilter [] = containedAnds[0].and
for (let i = 1; i < containedAnds.length && commonValues.length > 0; i++){
const containedAnd = containedAnds[i];
commonValues = commonValues.filter(cv => containedAnd.and.some(candidate => candidate.isEquivalent(cv)))
}
if(commonValues.length === 0){
newOrs.push(...containedAnds)
}else{
const newAnds: TagsFilter[] = []
for (const containedAnd of containedAnds) {
const elements = containedAnd.and.filter(candidate => !commonValues.some(cv => cv.isEquivalent(candidate)))
newAnds.push(new And(elements))
}
commonValues.push(new Or(newAnds))
const result = new And(commonValues).optimize()
if(result === true){
return true
}else if(result === false){
// neutral element: skip
}else{
newOrs.push(new And(commonValues))
}
}
}
if(newOrs.length === 1){
return newOrs[0]
}
TagUtils.sortFilters(newOrs, false)
return new Or(newOrs)
}
}

View file

@ -43,10 +43,24 @@ export class RegexTag extends TagsFilter {
}
asOverpass(): string[] {
if (typeof this.key === "string") {
return [`["${this.key}"${this.invert ? "!" : ""}~"${RegexTag.source(this.value)}"]`];
const inv =this.invert ? "!" : ""
if (typeof this.key !== "string") {
// The key is a regex too
return [`[~"${this.key.source}"${inv}~"${RegexTag.source(this.value)}"]`];
}
return [`[~"${this.key.source}"${this.invert ? "!" : ""}~"${RegexTag.source(this.value)}"]`];
if(this.value instanceof RegExp){
const src =this.value.source
if(src === "^..*$"){
// anything goes
return [`[${inv}"${this.key}"]`]
}
return [`["${this.key}"${inv}~"${src}"]`]
}else{
// Normal key and normal value
return [`["${this.key}"${inv}="${this.value}"]`];
}
}
isUsableAsAnswer(): boolean {
@ -100,6 +114,10 @@ export class RegexTag extends TagsFilter {
throw "Key cannot be determined as it is a regex"
}
usedTags(): { key: string; value: string }[] {
return [];
}
asChange(properties: any): { k: string; v: string }[] {
if (this.invert) {
return []
@ -120,4 +138,8 @@ export class RegexTag extends TagsFilter {
AsJson() {
return this.asHumanString()
}
optimize(): TagsFilter | boolean {
return this;
}
}

View file

@ -59,6 +59,10 @@ export default class SubstitutingTag implements TagsFilter {
return [this._key];
}
usedTags(): { key: string; value: string }[] {
return []
}
asChange(properties: any): { k: string; v: string }[] {
if (this._invert) {
throw "An inverted substituting tag can not be used to create a change"
@ -73,4 +77,8 @@ export default class SubstitutingTag implements TagsFilter {
AsJson() {
return this._key + (this._invert ? '!' : '') + "=" + this._value
}
optimize(): TagsFilter | boolean {
return this;
}
}

View file

@ -80,6 +80,13 @@ export class Tag extends TagsFilter {
return [this.key];
}
usedTags(): { key: string; value: string }[] {
if(this.value == ""){
return []
}
return [this]
}
asChange(properties: any): { k: string; v: string }[] {
return [{k: this.key, v: this.value}];
}
@ -87,4 +94,8 @@ export class Tag extends TagsFilter {
AsJson() {
return this.asHumanString(false, false)
}
optimize(): TagsFilter | boolean {
return this;
}
}

View file

@ -8,8 +8,10 @@ import SubstitutingTag from "./SubstitutingTag";
import {Or} from "./Or";
import {AndOrTagConfigJson} from "../../Models/ThemeConfig/Json/TagConfigJson";
import {isRegExp} from "util";
import * as key_counts from "../../assets/key_totals.json"
export class TagUtils {
private static keyCounts : {keys: any, tags: any} = key_counts["default"] ?? key_counts
private static comparators
: [string, (a: number, b: number) => boolean][]
= [
@ -174,6 +176,29 @@ export class TagUtils {
}
}
/**
* INLINE sort of the given list
*/
public static sortFilters(filters: TagsFilter [], usePopularity: boolean): void {
filters.sort((a,b) => TagUtils.order(a, b, usePopularity))
}
public static toString(f: TagsFilter, toplevel = true): string {
let r: string
if (f instanceof Or) {
r = TagUtils.joinL(f.or, "|", toplevel)
} else if (f instanceof And) {
r = TagUtils.joinL(f.and, "&", toplevel)
} else {
r = f.asHumanString(false, false, {})
}
if(toplevel){
r = r.trim()
}
return r
}
private static TagUnsafe(json: AndOrTagConfigJson | string, context: string = ""): TagsFilter {
if (json === undefined) {
@ -286,8 +311,9 @@ export class TagUtils {
}
if(json.and !== undefined && json.or !== undefined){
throw `Error while parsing a TagConfig: got an object where both 'and' and 'or' are defined`}
if (json.and !== undefined && json.or !== undefined) {
throw `Error while parsing a TagConfig: got an object where both 'and' and 'or' are defined`
}
if (json.and !== undefined) {
return new And(json.and.map(t => TagUtils.Tag(t, context)));
@ -296,4 +322,56 @@ export class TagUtils {
return new Or(json.or.map(t => TagUtils.Tag(t, context)));
}
}
private static GetCount(key: string, value?: string) {
if(key === undefined) {
return undefined
}
const tag = TagUtils.keyCounts.tags[key]
if(tag !== undefined && tag[value] !== undefined) {
return tag[value]
}
return TagUtils.keyCounts.keys[key]
}
private static order(a: TagsFilter, b: TagsFilter, usePopularity: boolean): number {
const rta = a instanceof RegexTag
const rtb = b instanceof RegexTag
if(rta !== rtb) {
// Regex tags should always go at the end: these use a lot of computation at the overpass side, avoiding it is better
if(rta) {
return 1 // b < a
}else {
return -1
}
}
if (a["key"] !== undefined && b["key"] !== undefined) {
if(usePopularity) {
const countA = TagUtils.GetCount(a["key"], a["value"])
const countB = TagUtils.GetCount(b["key"], b["value"])
if(countA !== undefined && countB !== undefined) {
return countA - countB
}
}
if (a["key"] === b["key"]) {
return 0
}
if (a["key"] < b["key"]) {
return -1
}
return 1
}
return 0
}
private static joinL(tfs: TagsFilter[], seperator: string, toplevel: boolean) {
const joined = tfs.map(e => TagUtils.toString(e, false)).join(seperator)
if (toplevel) {
return joined
}
return " (" + joined + ") "
}
}

View file

@ -12,6 +12,12 @@ export abstract class TagsFilter {
abstract usedKeys(): string[];
/**
* Returns all normal key/value pairs
* Regex tags, substitutions, comparisons, ... are exempt
*/
abstract usedTags(): {key: string, value: string}[];
/**
* Converts the tagsFilter into a list of key-values that should be uploaded to OSM.
* Throws an error if not applicable.
@ -21,4 +27,11 @@ export abstract class TagsFilter {
abstract asChange(properties: any): { k: string, v: string }[]
abstract AsJson() ;
/**
* Returns an optimized version (or self) of this tagsFilter
*/
abstract optimize(): TagsFilter | boolean;
}

View file

@ -2,7 +2,7 @@ import {Utils} from "../Utils";
export default class Constants {
public static vNumber = "0.17.0-alpha-1";
public static vNumber = "0.17.0-alpha-2";
public static ImgurApiKey = '7070e7167f0a25a'
public static readonly mapillary_client_token_v4 = "MLY|4441509239301885|b40ad2d3ea105435bd40c7e76993ae85"

229
assets/key_totals.json Normal file
View file

@ -0,0 +1,229 @@
{
"keys": {
"addr:street": 117211930,
"addr:housenumber": 125040768,
"emergency": 1939478,
"barrier": 18424246,
"tourism": 2683525,
"amenity": 20541353,
"bench": 894256,
"rental": 8838,
"bicycle_rental": 7447,
"vending": 206755,
"service:bicycle:rental": 3570,
"pub": 316,
"theme": 426,
"service:bicycle:.*": 0,
"service:bicycle:cleaning": 807,
"shop": 5062252,
"service:bicycle:retail": 9162,
"network": 2181336,
"sport": 2194801,
"service:bicycle:repair": 11381,
"association": 369,
"ngo": 42,
"leisure": 7368076,
"club": 38429,
"disused:amenity": 40880,
"planned:amenity": 205,
"tileId": 0,
"construction:amenity": 1206,
"cycleway": 906487,
"highway": 218189453,
"bicycle": 6218071,
"cyclestreet": 8185,
"camera:direction": 40676,
"direction": 1896015,
"access": 16030036,
"entrance": 2954076,
"name:etymology": 24485,
"memorial": 132172,
"indoor": 353116,
"name:etymology:wikidata": 285224,
"landuse": 35524214,
"name": 88330405,
"protect_class": 73801,
"information": 831513,
"man_made": 5116088,
"boundary": 2142378,
"tower:type": 451658,
"playground": 109175,
"route": 939184,
"surveillance:type": 116760,
"natural": 52353504,
"building": 500469053
},
"tags": {
"emergency": {
"defibrillator": 51273,
"ambulance_station": 11047,
"fire_extinguisher": 7355,
"fire_hydrant": 1598739
},
"barrier": {
"cycle_barrier": 104166,
"bollard": 502220,
"wall": 3535056
},
"tourism": {
"artwork": 187470,
"map": 51,
"viewpoint": 191765
},
"amenity": {
"bench": 1736979,
"bicycle_library": 36,
"bicycle_rental": 49082,
"vending_machine": 201871,
"bar": 199662,
"pub": 174979,
"cafe": 467521,
"restaurant": 1211671,
"bicycle_wash": 44,
"bike_wash": 0,
"bicycle_repair_station": 9247,
"bicycle_parking": 435959,
"binoculars": 479,
"biergarten": 10309,
"charging_station": 65402,
"drinking_water": 250463,
"fast_food": 460079,
"fire_station": 122200,
"parking": 4255206,
"public_bookcase": 13120,
"toilets": 350648,
"recycling": 333925,
"waste_basket": 550357,
"waste_disposal": 156765
},
"bench": {
"stand_up_bench": 87,
"yes": 524993
},
"service:bicycle:rental": {
"yes": 3054
},
"pub": {
"cycling": 9,
"bicycle": 0
},
"theme": {
"cycling": 8,
"bicycle": 16
},
"service:bicycle:cleaning": {
"yes": 607,
"diy": 0
},
"shop": {
"bicycle": 46488,
"sports": 37024
},
"sport": {
"cycling": 6045,
"bicycle": 96
},
"association": {
"cycling": 5,
"bicycle": 20
},
"ngo": {
"cycling": 0,
"bicycle": 0
},
"leisure": {
"bird_hide": 5669,
"nature_reserve": 117016,
"picnic_table": 206322,
"pitch": 1990293,
"playground": 705102
},
"club": {
"cycling": 3,
"bicycle": 49
},
"disused:amenity": {
"charging_station": 164
},
"planned:amenity": {
"charging_station": 115
},
"construction:amenity": {
"charging_station": 221
},
"cycleway": {
"lane": 314576,
"track": 86541,
"shared_lane": 60824
},
"highway": {
"residential": 61321708,
"crossing": 6119521,
"cycleway": 1423789,
"traffic_signals": 1512639,
"tertiary": 7051727,
"unclassified": 15756878,
"secondary": 4486617,
"primary": 3110552,
"footway": 16496620,
"path": 11438303,
"steps": 1327396,
"corridor": 27051,
"pedestrian": 685989,
"bridleway": 102280,
"track": 22670967,
"living_street": 1519108,
"street_lamp": 2811705
},
"bicycle": {
"designated": 1110839
},
"cyclestreet": {
"yes": 8164
},
"access": {
"public": 6222,
"yes": 1363526
},
"memorial": {
"ghost_bike": 503
},
"indoor": {
"door": 9722
},
"landuse": {
"grass": 4898559,
"village_green": 104681
},
"name": {
"Park Oude God": 1
},
"information": {
"board": 242007,
"map": 85912,
"office": 24139,
"visitor_centre": 285
},
"man_made": {
"surveillance": 148172,
"watermill": 9699
},
"boundary": {
"protected_area": 97075
},
"tower:type": {
"observation": 19654
},
"playground": {
"forest": 56
},
"surveillance:type": {
"camera": 112963,
"ALPR": 2522,
"ANPR": 3
},
"natural": {
"tree": 18245059
}
}
}

77
scripts/generateStats.ts Normal file
View file

@ -0,0 +1,77 @@
import * as known_layers from "../assets/generated/known_layers.json"
import {LayerConfigJson} from "../Models/ThemeConfig/Json/LayerConfigJson";
import {TagUtils} from "../Logic/Tags/TagUtils";
import {Utils} from "../Utils";
import {writeFileSync} from "fs";
import ScriptUtils from "./ScriptUtils";
import Constants from "../Models/Constants";
/* Downloads stats on osmSource-tags and keys from tagInfo */
async function main(includeTags = true) {
ScriptUtils.fixUtils()
const layers: LayerConfigJson[] = (known_layers["default"] ?? known_layers).layers
const keysAndTags = new Map<string, Set<string>>()
for (const layer of layers) {
if (layer.source["geoJson"] !== undefined && !layer.source["isOsmCache"]) {
continue
}
if (Constants.priviliged_layers.indexOf(layer.id) >= 0) {
continue
}
const sources = TagUtils.Tag(layer.source.osmTags)
const allKeys = sources.usedKeys()
for (const key of allKeys) {
if (!keysAndTags.has(key)) {
keysAndTags.set(key, new Set<string>())
}
}
const allTags = includeTags ? sources.usedTags() : []
for (const tag of allTags) {
if (!keysAndTags.has(tag.key)) {
keysAndTags.set(tag.key, new Set<string>())
}
keysAndTags.get(tag.key).add(tag.value)
}
}
const keyTotal = new Map<string, number>()
const tagTotal = new Map<string, Map<string, number>>()
await Promise.all(Array.from(keysAndTags.keys()).map(async key => {
const values = keysAndTags.get(key)
const data = await Utils.downloadJson(`https://taginfo.openstreetmap.org/api/4/key/stats?key=${key}`)
const count = data.data.find(item => item.type === "all").count
keyTotal.set(key, count)
console.log(key, "-->", count)
if (values.size > 0) {
tagTotal.set(key, new Map<string, number>())
await Promise.all(
Array.from(values).map(async value => {
const tagData = await Utils.downloadJson(`https://taginfo.openstreetmap.org/api/4/tag/stats?key=${key}&value=${value}`)
const count = tagData.data.find(item => item.type === "all").count
tagTotal.get(key).set(value, count)
console.log(key + "=" + value, "-->", count)
})
)
}
}))
writeFileSync("./assets/key_totals.json",
JSON.stringify(
{
keys: Utils.MapToObj(keyTotal),
tags: Utils.MapToObj(tagTotal, v => Utils.MapToObj(v))
},
null, " "
)
)
}
main().then(() => console.log("All done"))

File diff suppressed because one or more lines are too long

View file

@ -9,6 +9,9 @@ import {Tag} from "../Logic/Tags/Tag";
import {And} from "../Logic/Tags/And";
import {TagUtils} from "../Logic/Tags/TagUtils";
import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig";
import {TagsFilter} from "../Logic/Tags/TagsFilter";
import {Or} from "../Logic/Tags/Or";
import {RegexTag} from "../Logic/Tags/RegexTag";
export default class TagSpec extends T {
@ -21,6 +24,133 @@ export default class TagSpec extends T {
equal(tr.txt, "Test value abc");
}],
["Optimize tags", () => {
let t : TagsFilter= new And(
[
new And([
new Tag("x", "y")
]),
new Tag("a", "b")
]
)
let opt =<TagsFilter> t.optimize()
console.log(TagUtils.toString(opt))
T.equals(`a=b&x=y`,TagUtils.toString(opt), "Optimization failed")
// foo&bar & (x=y | a=b) & (x=y | c=d) & foo=bar is equivalent too foo=bar & ((x=y) | (a=b & c=d))
t = new And([
new Tag("foo","bar"),
new Or([
new Tag("x", "y"),
new Tag("a", "b")
]),
new Or([
new Tag("x", "y"),
new Tag("c", "d")
])
])
opt =<TagsFilter> t.optimize()
console.log(TagUtils.toString(opt))
T.equals(TagUtils.toString(opt), "foo=bar& (x=y| (a=b&c=d) )")
t = new Or([
new Tag("foo","bar"),
new And([
new Tag("foo", "bar"),
new Tag("x", "y"),
])
])
opt =<TagsFilter> t.optimize()
console.log(TagUtils.toString(opt))
T.equals("foo=bar", TagUtils.toString(opt), "Optimizing away an unneeded factor failed")
t = new And([
new RegexTag("x","y"),
new Tag("a","b")
])
opt =<TagsFilter> t.optimize()
T.equals("a=b&x~^y$", TagUtils.toString(opt), "Regexes go to the end")
t = new And([
new Tag("bicycle","yes"),
new Tag("amenity","binoculars")
])
opt =<TagsFilter> t.optimize()
T.equals("amenity=binoculars&bicycle=yes", TagUtils.toString(opt), "Common keys go to the end")
const filter = TagUtils.Tag( {or: [
{
"and": [
{
"or": ["amenity=charging_station","disused:amenity=charging_station","planned:amenity=charging_station","construction:amenity=charging_station"]
},
"bicycle=yes"
]
},
{
"and": [
{
"or": ["amenity=charging_station","disused:amenity=charging_station","planned:amenity=charging_station","construction:amenity=charging_station"]
},
]
},
"amenity=toilets",
"amenity=bench",
"leisure=picnic_table",
{
"and": [
"tower:type=observation"
]
},
{
"and": [
"amenity=bicycle_repair_station"
]
},
{
"and": [
{
"or": [
"amenity=bicycle_rental",
"bicycle_rental~*",
"service:bicycle:rental=yes",
"rental~.*bicycle.*"
]
},
"bicycle_rental!=docking_station"
]
},
{
"and": [
"leisure=playground",
"playground!=forest"
]
}
]});
opt = <TagsFilter> filter.optimize()
console.log(TagUtils.toString(opt))
T.equals(("amenity=charging_station|" +
"amenity=toilets|" +
"amenity=bench|" +
"amenity=bicycle_repair_station" +
"|construction:amenity=charging_station|" +
"disused:amenity=charging_station|" +
"leisure=picnic_table|" +
"planned:amenity=charging_station|" +
"tower:type=observation| " +
"( (amenity=bicycle_rental|service:bicycle:rental=yes|bicycle_rental~^..*$|rental~^.*bicycle.*$) &bicycle_rental!~^docking_station$) |" +
" (leisure=playground&playground!~^forest$)").replace(/ /g, ""),
TagUtils.toString(opt).replace(/ /g, ""), "Advanced case failed")
}],
["Parse tag config", (() => {
const tag = TagUtils.Tag("key=value") as Tag;
equal(tag.key, "key");
@ -217,6 +347,17 @@ export default class TagSpec extends T {
equal(3, overpassOrInor.length)
}
],
[
"Test regex to overpass",() => {
/*(Specifiation to parse, expected value for new RegexTag(spec).asOverpass()[0]) */
[["a~*", `"a"`],
["a~[xyz]",`"a"~"^[xyz]$"`]].forEach(([spec, expected]) =>{
T.equals(`[${expected}]`, TagUtils.Tag(
spec
).asOverpass()[0], "RegexRendering failed")
} )
}
],
[
"Merge touching opening hours",
() => {

View file

@ -20,11 +20,11 @@ export default class T {
}
}
static equals(a, b, msg?) {
if (a !== b) {
static equals(expected, got, msg?) {
if (expected !== got) {
throw "Not the same: " + (msg ?? "") + "\n" +
"Expcected: " + a + "\n" +
"Got : " + b
"Expected: " + expected + "\n" +
"Got : " + got
}
}
@ -45,7 +45,7 @@ export default class T {
throw `ListIdentical failed: expected a list of length ${expected.length} but got a list of length ${actual.length}`
}
for (let i = 0; i < expected.length; i++) {
if (expected[i] !== undefined && expected[i]["length"] !== undefined) {
if (Array.isArray(expected[i])) {
T.listIdentical(<any>expected[i], <any>actual[i])
} else if (expected[i] !== actual[i]) {
throw `ListIdentical failed at index ${i}: expected ${expected[i]} but got ${actual[i]}`