Add wesnoth-map-diff

This commit is contained in:
macabeus 2022-03-04 23:30:49 +00:00 committed by Pentarctagon
parent a4955a39fa
commit bc387de7e9
23 changed files with 13658 additions and 0 deletions

82
.github/workflows/map-diff.yml vendored Normal file
View file

@ -0,0 +1,82 @@
name: Map Diff
on:
pull_request:
paths:
- '**.map'
jobs:
comment-map-diff:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v3.0.0
- uses: actions/setup-node@v3.0.0
with:
node-version: '16'
- name: Package install
run: |
cd ./utils/wesnoth-map-diff
npm install
- name: Package build
run: |
cd ./utils/wesnoth-map-diff
npm run build:prod
- name: Get maps diff
id: get-maps-diff
run: |
cd ./utils/wesnoth-map-diff
## Get Maps
git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
map_paths=($(git diff --name-only HEAD ${{ github.event.pull_request.base.sha }} | grep ".map$"))
for map_path in "${map_paths[@]}"
do
map_filename=$(basename "$map_path")
git show ${{ github.event.pull_request.base.sha }}:$map_path > $map_filename
done
## Run map diff
diff_images=()
for map_path in "${map_paths[@]}"
do
map_filename=$(basename "$map_path")
output_filename=$(echo "$map_filename" | perl -pe 's/map$/png/')
node ./build/index.js "./$map_filename" "../../$map_path" "./$output_filename"
diff_images+=("$output_filename")
done
## Compress images
sudo apt-get install -y pngquant
for diff_image in "${diff_images[@]}"
do
pngquant "$diff_image" -o "$diff_image" --force
done
## Write comment body
comment_body=""
for i in "${!diff_images[@]}"
do
map_path=${map_paths[$i]}
diff_image=${diff_images[$i]}
image_link=$(curl -X POST "https://api.imgur.com/3/upload" \
-F "image=@\"$diff_image\"" | jq ".data.link" -r)
comment_body="$comment_body<h3>$map_path</h3><img src=\"$image_link\" /><br />"
done
echo "::set-output name=COMMENT_BODY::$comment_body"
- name: Add comment
uses: peter-evans/create-or-update-comment@v1.4.5
with:
issue-number: ${{ github.event.pull_request.number }}
edit-mode: replace
body: |
${{ steps.get-maps-diff.outputs.COMMENT_BODY }}

View file

@ -0,0 +1 @@
build

View file

@ -0,0 +1,81 @@
module.exports = {
parser: '@typescript-eslint/parser',
plugins: [
'@typescript-eslint',
],
overrides: [
{
files: ['**/*.ts'],
},
],
extends: [
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
],
rules: {
'@typescript-eslint/member-delimiter-style': ['error', {
multiline: {
delimiter: 'comma',
requireLast: true,
},
singleline: {
delimiter: 'comma',
requireLast: false,
},
}],
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-empty-interface': [
'error',
{
allowSingleExtends: true,
},
],
'@typescript-eslint/no-var-requires': 'off',
'arrow-parens': 'off',
'comma-dangle': [
'error',
{
arrays: 'always-multiline',
exports: 'always-multiline',
functions: 'never',
imports: 'always-multiline',
objects: 'always-multiline',
},
],
'function-paren-newline': ['error', 'consistent'],
'quote-props': ['error', 'as-needed'],
'max-len': [
'error',
{
code: 120,
ignoreComments: true,
ignoreRegExpLiterals: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
ignoreTrailingComments: true,
ignoreUrls: true,
tabWidth: 2,
},
],
'multiline-ternary': ['error', 'always-multiline'],
'no-unused-expressions': ['error', { allowTernary: true }],
'no-multiple-empty-lines': [
'error',
{
max: 1,
maxEOF: 1,
},
],
'implicit-arrow-linebreak': 'off',
'import/extensions': 'off',
'import/no-unresolved': 'off',
semi: ['error', 'never'],
'space-before-function-paren': ['error', 'always'],
'object-curly-spacing': ['error', 'always'],
'operator-linebreak': [
'error',
'before',
{ overrides: { '=': 'after', ':': 'before', '?': 'before' } },
],
},
}

4
utils/wesnoth-map-diff/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
.eslintcache
node_modules/
build/

View file

@ -0,0 +1,39 @@
# wesnoth-map-diff
> 🗺 Print the diff between two maps
## Setup
1 - Make sure you have Node on your system. If you don't have, install it. You can use [nvm](https://github.com/nvm-sh/nvm).
2 - Install the project dependencies:
```
npm i
```
3 - Build it:
```
npm run build:dev
```
4 - Run it:
```
node ./build/index.js old.map new.map output.png
```
## Contributing
Run tests:
```
npm run test
```
Run lint:
```
npm run lint
```

View file

@ -0,0 +1,6 @@
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript',
],
}

View file

@ -0,0 +1,3 @@
module.exports = {
setupFilesAfterEnv: ['./setup-jest.js'],
}

12550
utils/wesnoth-map-diff/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,30 @@
{
"name": "wesnoth-map-diff",
"version": "0.0.1",
"description": "Visual map differ for Battle for Wesnoth",
"scripts": {
"build:dev": "tsc -p tsconfig-dev.json",
"build:prod": "tsc -p tsconfig-prod.json",
"test": "jest",
"lint": "eslint . --cache"
},
"author": "macabeus",
"license": "GNU GPL v2+",
"dependencies": {
"jimp": "0.16.1",
"parsimmon": "1.18.1"
},
"devDependencies": {
"@babel/preset-env": "7.16.11",
"@babel/preset-typescript": "7.16.7",
"@types/jest": "27.4.0",
"@types/parsimmon": "1.10.6",
"@typescript-eslint/eslint-plugin": "5.12.1",
"@typescript-eslint/parser": "5.12.1",
"eslint": "8.10.0",
"fast-check": "2.21.0",
"jest": "27.5.0",
"jest-fast-check": "1.0.2",
"typescript": "4.5.5"
}
}

14
utils/wesnoth-map-diff/setup-jest.d.ts vendored Normal file
View file

@ -0,0 +1,14 @@
interface CustomMatchers<R = unknown> {
parserGot(value: unknown): R,
parserFailedWith(expected: string[]): R,
}
declare global {
namespace jest {
interface Expect extends CustomMatchers {}
interface Matchers<R> extends CustomMatchers<R> {}
interface InverseAsymmetricMatchers extends CustomMatchers {}
}
}
export {}

View file

@ -0,0 +1,39 @@
expect.extend({
parserGot (received, value) {
try {
expect(received).toMatchObject({
status: true,
value,
})
return {
pass: true,
message: () => `Expects for ${JSON.stringify(value)}`,
}
} catch (e) {
return {
pass: false,
message: () => e.matcherResult.message,
}
}
},
parserFailedWith (received, expected) {
try {
expect(received).toMatchObject({
status: false,
expected,
})
return {
pass: true,
message: () => `Expects for ${JSON.stringify(expected)}`,
}
} catch (e) {
return {
pass: false,
message: () => e.matcherResult.message,
}
}
},
})

View file

@ -0,0 +1,60 @@
import path from 'path'
import Jimp from 'jimp'
import { parseWmlFile } from './wml/parser'
import rootByTagName from './wml/rootByTagName'
type ImagesDict = { tile: { [baseCode: string]: Jimp }, focus: Jimp, flag: Jimp }
const getDictTerrainType2ImagesPath = async () => {
const terratinCfgPath = path.resolve(__dirname, '../../../data/core/terrain.cfg')
const terrain = await parseWmlFile(terratinCfgPath)
const dictTerrainType2ImagesPath = rootByTagName(terrain)
.terrain_type
.reduce((acc, terrainType) => {
if (terrainType.symbol_image === undefined) {
return acc
}
acc[terrainType.string.value] = terrainType.symbol_image.value
return acc
}, {} as { [terrainType: string]: string })
return dictTerrainType2ImagesPath
}
const readTerrainImages = async () => {
const imageBasepath = path.resolve(__dirname, '../../../data/core/images/terrain')
const dictTerrainType2ImagesPath = await getDictTerrainType2ImagesPath()
const promises = Object
.entries(dictTerrainType2ImagesPath)
.map(async ([terrainType, imageName]) => {
const image = await Jimp.read(`${imageBasepath}/${imageName}.png`)
return [terrainType, image]
})
const result = Object.fromEntries(await Promise.all(promises))
return result
}
const getImages = async (): Promise<ImagesDict> => {
const focusPath = path.resolve(__dirname, '../../../images/editor/brush.png')
const flagPath = path.resolve(__dirname, '../../../data/core/images/flags/flag-1.png')
const mapTileCodeToImage = readTerrainImages()
const focus = Jimp.read(focusPath)
const flag = Jimp.read(flagPath)
return {
tile: await mapTileCodeToImage,
focus: await focus,
flag: await flag,
}
}
export { ImagesDict }
export default getImages

View file

@ -0,0 +1,17 @@
import getImages from './images'
import * as tilemap from './tilemap'
import paint from './paint'
const main = async () => {
const [oldMapPath, newMapPath, outputFilename] = [process.argv[2], process.argv[3], process.argv[4]]
const [oldTilemap, newTilemap, images] = await Promise.all([
tilemap.parseFile(oldMapPath),
tilemap.parseFile(newMapPath),
getImages(),
])
await paint(oldTilemap, newTilemap, outputFilename, images)
}
main()

View file

@ -0,0 +1,97 @@
import Jimp from 'jimp'
import * as tilemap from './tilemap'
import type { Tilemap } from './tilemap'
import type { ImagesDict } from './images'
type Side = 'left' | 'right'
const tileImageSize = 72
const imageSize = (map: Tilemap) => {
const { tilemapWidth, tilemapHeight } = tilemap.size(map)
const imageHeight = tilemapHeight * tileImageSize
const imageWidth = tileImageSize * tilemapWidth - (tilemapWidth - 1) * 18
return { width: imageWidth, height: imageHeight }
}
const getTileImageCoordenates = (tileX: number, tileY: number) => {
const imageX = tileX * tileImageSize - 18 * tileX
const imageY = tileX % 2 === 0
? tileY * tileImageSize
: tileY * tileImageSize - tileImageSize / 2
return [imageX, imageY]
}
const producePainters = (output: Jimp, images: ImagesDict, leftPadding: number) => {
const paintTile = (x: number, y: number, baseCode: string) => {
const baseImage = images.tile[baseCode]
output.composite(baseImage, x + leftPadding, y)
}
// todo: we should use the defaultBase correctly
const paintMisc = (x: number, y: number, baseCode: string, miscCode: string) => {
if (!miscCode) {
return
}
const misctileImage = images.tile[`${baseCode}^${miscCode}`] || images.tile[`^${miscCode}`]
output.composite(misctileImage, x + leftPadding, y)
}
// todo: the flag color should follow the player number
const paintPlayer = (x: number, y: number, player: string) => {
if (!player) {
return
}
output.composite(images.flag, x + leftPadding, y)
}
return { paintTile, paintMisc, paintPlayer }
}
const paint = async (
oldTilemap: Tilemap,
newTilemap: Tilemap,
outputFilename: string,
images: ImagesDict
) => {
const { height, width } = imageSize(oldTilemap)
const diffImageWidth = width * 2 + tileImageSize
const diffImageHeight = height
new Jimp(diffImageWidth, diffImageHeight, (_err, output) => {
const paintTilemap = (map: Tilemap, side: Side) => {
const leftPadding = side === 'left'
? 0
: width + tileImageSize
const { paintTile, paintMisc, paintPlayer } = producePainters(output, images, leftPadding)
tilemap.walkthrough(map, ({ x, y, baseCode, miscCode, player }) => {
const [imageX, imageY] = getTileImageCoordenates(x, y)
paintTile(imageX, imageY, baseCode)
paintMisc(imageX, imageY, baseCode, miscCode)
paintPlayer(imageX, imageY, player)
})
tilemap.diff(oldTilemap, newTilemap).forEach(([x, y]) => {
const [imageX, imageY] = getTileImageCoordenates(x, y)
output.composite(images.focus, imageX + leftPadding, imageY)
})
}
paintTilemap(oldTilemap, 'left')
paintTilemap(newTilemap, 'right')
output.write(outputFilename)
})
}
export default paint

View file

@ -0,0 +1,98 @@
import fs from 'fs'
type Tilemap = {
baseCode: string,
miscCode: string,
player: string,
}[][]
const parseRawMap = (rawMap: string) => {
return rawMap
.slice(0, -1)
.split('\n')
.map(
row =>
row
.split(', ')
.map((rawTile) => {
const [baseCode, miscCode] = rawTile.replace(/\d\s/, '').split('^')
const [player] = /\d/.test(rawTile) ? rawTile.match(/\d/)! : []
return { baseCode, miscCode, player }
})
)
}
const parseFile = (path: string) => {
return new Promise<Tilemap>((resolve, reject) => {
fs.readFile(path, (err, data) => {
if (err !== null) {
reject(err)
return
}
const raw = data.toString()
const parsed = parseRawMap(raw)
resolve(parsed)
})
})
}
const size = (map: Tilemap) => ({ tilemapWidth: map[0].length, tilemapHeight: map.length })
type WalkthroughCallback =
(
{
x,
y,
baseCode,
miscCode,
player,
}: {
x: number,
y: number,
baseCode: string,
miscCode: string,
player: string,
}
) => void
const walkthrough = (tilemap: Tilemap, callback: WalkthroughCallback) => {
let x = 0
let y = 0
for (let i = 0; i < tilemap.length * tilemap[0].length; i += 1) {
const tile = tilemap[y][x]
callback({
x,
y,
baseCode: tile.baseCode,
miscCode: tile.miscCode,
player: tile.player,
})
x += 1
if (x === tilemap[0].length) {
y += 1
x = 0
}
}
}
const diff = (left: Tilemap, right: Tilemap) => {
const diffTiles: Array<[number, number]> = []
walkthrough(left, ({ x, y }) => {
if (
left[y][x].baseCode !== right[y][x].baseCode
|| left[y][x].miscCode !== right[y][x].miscCode
|| left[y][x].player !== right[y][x].player
) {
diffTiles.push([x, y])
}
})
return diffTiles
}
export { Tilemap, parseFile, size, walkthrough, diff }

View file

@ -0,0 +1,140 @@
import { createLanguage, seqMap, alt, regex, string, whitespace, Result } from 'parsimmon'
import fs from 'fs'
const WmlLang = createLanguage({
File: (r) => {
return r
.TopLevel
.many()
.map((nodes) => {
const tagsList = nodes.filter(node => typeof node !== 'string')
return tagsList
})
},
TopLevel: (r) => {
return alt(whitespace, r.Comment, r.FullTag)
},
Comment: () => {
return regex(/\s*#.*\n/)
},
FullTag: (r) => {
return r
.OpenTag
.chain((tagName) => {
return alt(
whitespace,
r.FullAttribute,
r.Comment
)
.desc('Tag body')
.many()
.map((lines) => {
const attrsArray = lines.filter(line => typeof line !== 'string')
const attrsObj = attrsArray.reduce((acc, attr) => {
acc[attr.key] = attr.value
return acc
}, {})
return [tagName, attrsObj]
})
.skip(
string(`[/${tagName}]`)
.desc(`Close tag for [${tagName}]`)
)
})
},
OpenTag: () => {
return seqMap(
string('['),
regex(/[a-zA-Z0-9_]+/),
string(']'),
(_, tagName) => tagName
)
},
FullAttribute: (r) => {
return seqMap(
r.AttributeKey,
r.AttributeValue,
(key, value) => (
{ key, value }
)
)
},
AttributeKey: () => {
return seqMap(
regex(/[a-zA-Z0-9_]+/),
regex(/\s*=\s*/),
(key) => key
)
},
AttributeValue: (r) => {
return alt(
r.AttributeValueString,
r.AttributeValueText
)
},
AttributeValueText: () => {
return seqMap(
regex(/(_ )?/),
regex(/[^+\n]*/),
(translatable, value) => (
{
value: value.trim(),
translatable: Boolean(translatable),
}
)
)
},
AttributeValueString: () => {
return seqMap(
regex(/(_ )?/),
string('"'),
regex(/([^"]|"")*/),
string('"'),
(translatable, _, value) => (
{
value,
translatable: Boolean(translatable),
}
)
)
},
})
type WmlParsed = Array<
[
string,
{ [attributeName: string]: { translatable: boolean, value: string } }
]
>
const parseWml = (source: string) =>
WmlLang.File.parse(source) as Result<WmlParsed>
const tryParseWml = (source: string) =>
WmlLang.File.tryParse(source) as WmlParsed
const parseWmlFile = (path: string) => {
return new Promise<WmlParsed>((resolve, reject) => {
fs.readFile(path, (err, data) => {
if (err !== null) {
reject(err)
return
}
const rawTerrainCfg = data.toString()
const parsed = tryParseWml(rawTerrainCfg)
resolve(parsed)
})
})
}
export { WmlParsed, parseWml, tryParseWml, parseWmlFile }

View file

@ -0,0 +1,14 @@
import type { WmlParsed } from './parser'
const rootByTagName = (parsed: WmlParsed) => {
return parsed.reduce((acc, [tagName, tagAttrs]) => {
if (acc[tagName] === undefined) {
acc[tagName] = []
}
acc[tagName].push(tagAttrs)
return acc
}, {} as { [tagName: string]: Array<{ [attributeName: string]: { translatable: boolean, value: string } }> })
}
export default rootByTagName

View file

@ -0,0 +1,102 @@
import * as fc from 'fast-check'
const keyChars = [
...Array.from(new Array(10), (_, index) => String.fromCharCode(48 + index)), // 0-9
...Array.from(new Array(24), (_, index) => String.fromCharCode(65 + index)), // A-Z
...Array.from(new Array(24), (_, index) => String.fromCharCode(97 + index)), // a-z
'_',
]
const valueChars = [
...keyChars, '/', '-', '!', '@', '$', '%', '^', '&', '*', '(', ')', '[', ']', ' ',
]
const wmlName = () => fc.stringOf(
fc.constantFrom(...keyChars), { minLength: 1 }
)
const wmlValue = () => fc.stringOf(
fc.constantFrom(...valueChars), { minLength: 1 }
).filter((value) => (value.trimStart().length > 0) && (value.includes('_ ') === false))
const wmlValueString = () => fc.stringOf(
fc.constantFrom(...valueChars, '\n', '"'), { minLength: 1 }
).filter((value) => (value.trimStart().length > 0) && (value.includes('_ ') === false)).map((value) => value.replace(/"/g, '""test quote""'))
const wmlValueTranslatable = (): fc.Arbitrary<string> => {
return fc.convertFromNext(
fc.convertToNext(wmlValue()).map(
(t) => `_ ${t}`,
(t) => (t as string).replace('_ ', '')
)
)
}
const fullAttributeUnmapper = (fullAttribute: string) => {
const [key, keyPadding, valuePadding, value] = fullAttribute.split(/(\s*)=(\s*)/)
return { key, keyPadding: keyPadding.length, valuePadding: valuePadding.length, value }
}
const fullAttributeStringUnmapper = (fullAttributeString: string) => {
const [key, keyPadding, valuePadding, rawValue] = fullAttributeString.split(/(\s*)=(\s*)/)
const value = rawValue.replace(/^"/, '').replace(/"$/, '')
return { key, keyPadding: keyPadding.length, valuePadding: valuePadding.length, value }
}
const fullAttribute = (): fc.Arbitrary<string> => {
return fc.convertFromNext(
fc.convertToNext(
fc.tuple(
wmlName(),
fc.nat({ max: 5 }),
fc.nat({ max: 5 }),
wmlValue()
)
).map(
([key, keyPadding, valuePadding, value]) => `${key}${' '.repeat(keyPadding)}=${' '.repeat(valuePadding)}${value}`,
(attribute) => {
if (typeof attribute !== 'string') {
throw new Error('Invalid type')
}
const segments = fullAttributeUnmapper(attribute)
return [segments.key, segments.keyPadding, segments.valuePadding, segments.value]
}
)
)
}
const fullAttributeString = (): fc.Arbitrary<string> => {
return fc.convertFromNext(
fc.convertToNext(
fc.tuple(
wmlName(),
fc.nat({ max: 5 }),
fc.nat({ max: 5 }),
wmlValueString()
)
).map(
([key, keyPadding, valuePadding, value]) => `${key}${' '.repeat(keyPadding)}=${' '.repeat(valuePadding)}"${value}"`,
(attribute) => {
if (typeof attribute !== 'string') {
throw new Error('Invalid type')
}
const segments = fullAttributeStringUnmapper(attribute)
return [segments.key, segments.keyPadding, segments.valuePadding, segments.value]
}
)
)
}
export {
wmlName,
wmlValue,
wmlValueTranslatable,
fullAttributeUnmapper,
fullAttribute,
fullAttributeString,
fullAttributeStringUnmapper,
}

View file

@ -0,0 +1,164 @@
import { testProp, fc } from 'jest-fast-check'
import { parseWml } from '../src/wml/parser'
import { wmlName, fullAttributeUnmapper, fullAttribute, fullAttributeString, fullAttributeStringUnmapper } from './arbitraries'
describe('#WML Parser', () => {
describe('when source is empty', () => {
it('returns empty', () => {
const source = ''
const parsed = parseWml(source)
expect(parsed).parserGot([])
})
})
describe('when tag is empty', () => {
testProp('returns an empty object for the tag', [wmlName()], (tagName) => {
const source = `
[${tagName}]
[/${tagName}]
`
const parsed = parseWml(source)
expect(parsed).parserGot([[tagName, {}]])
})
})
describe('when tag has one node', () => {
describe('which has a text value', () => {
testProp('returns an object with its attributes', [wmlName(), fullAttribute()], (tagName, attribute) => {
const source = `
[${tagName}]
${attribute}
[/${tagName}]
`
const parsed = parseWml(source)
const segments = fullAttributeUnmapper(attribute)
expect(parsed).parserGot(
[[tagName, { [segments.key]: { translatable: false, value: segments.value.trim() } }]]
)
})
})
describe('which has a string value', () => {
testProp('returns an object with its attributes', [wmlName(), fullAttributeString()], (tagName, attribute) => {
const source = `
[${tagName}]
${attribute}
[/${tagName}]
`
const parsed = parseWml(source)
const segments = fullAttributeStringUnmapper(attribute)
expect(parsed).parserGot(
[[tagName, { [segments.key]: { translatable: false, value: segments.value } }]]
)
})
})
})
describe('when there are two tags', () => {
describe('with the same name', () => {
testProp(
'returns both tags with its attributes',
[wmlName(), fullAttribute(), fullAttribute()],
(tagName, firstAttribute, secondAttribute) => {
const source = `
[${tagName}]
${firstAttribute}
[/${tagName}]
[${tagName}]
${secondAttribute}
[/${tagName}]
`
const parsed = parseWml(source)
const firstSegments = fullAttributeUnmapper(firstAttribute)
const secondSegments = fullAttributeUnmapper(secondAttribute)
expect(parsed).parserGot(
[
[tagName, { [firstSegments.key]: { translatable: false, value: firstSegments.value.trim() } }],
[tagName, { [secondSegments.key]: { translatable: false, value: secondSegments.value.trim() } }],
]
)
}
)
})
describe('with different names', () => {
testProp(
'returns both tags with its attributes',
[
fc.tuple(wmlName(), wmlName()).filter(([first, second]) => first !== second),
fullAttribute(),
fullAttribute(),
],
([firstTagName, secondTagName], firstAttribute, secondAttribute) => {
const source = `
[${firstTagName}]
${firstAttribute}
[/${firstTagName}]
[${secondTagName}]
${secondAttribute}
[/${secondTagName}]
`
const parsed = parseWml(source)
const firstSegments = fullAttributeUnmapper(firstAttribute)
const secondSegments = fullAttributeUnmapper(secondAttribute)
expect(parsed).parserGot(
[
[firstTagName, { [firstSegments.key]: { translatable: false, value: firstSegments.value.trim() } }],
[secondTagName, { [secondSegments.key]: { translatable: false, value: secondSegments.value.trim() } }],
]
)
}
)
})
})
describe('when tag is not closed', () => {
describe('because of missing the the closed one', () => {
testProp('raises an error', [wmlName(), fc.oneof(fullAttribute(), fullAttributeString(), fc.constant(''))], (tagName, tagBody) => {
const source = `
[${tagName}]
${tagBody}
`
const parsed = parseWml(source)
expect(parsed).parserFailedWith([`Close tag for [${tagName}]`, 'Tag body'])
})
})
describe('because of unmatched pair name', () => {
testProp(
'raises an error',
[
fc.tuple(wmlName(), wmlName()).filter(([first, second]) => first !== second),
fc.oneof(fullAttribute(), fullAttributeString(), fc.constant('')),
],
([firstTagName, secondTagName], tagBody) => {
const source = `
[${firstTagName}]
${tagBody}
[${secondTagName}]
`
const parsed = parseWml(source)
expect(parsed).parserFailedWith([`Close tag for [${firstTagName}]`, 'Tag body'])
}
)
})
})
})

View file

@ -0,0 +1,86 @@
import { testProp, fc } from 'jest-fast-check'
import type { WmlParsed } from '../src/wml/parser'
import rootByTagName from '../src/wml/rootByTagName'
import { wmlName } from './arbitraries'
describe('#RootByTagName', () => {
describe('when there is no tags', () => {
it('returns empty', () => {
const parsed: WmlParsed = []
const rooted = rootByTagName(parsed)
expect(rooted).toStrictEqual({})
})
})
describe('when has only one tag', () => {
testProp(
'includes it on the rooted object',
[
wmlName(),
fc.dictionary(wmlName(), fc.record({ translatable: fc.boolean(), value: fc.string() })),
],
(tagName, attributes) => {
const parsed: WmlParsed = [
[tagName, attributes],
]
const rooted = rootByTagName(parsed)
expect(rooted).toStrictEqual({
[tagName]: [attributes],
})
}
)
})
describe('when has two tags', () => {
describe('with the same name', () => {
testProp(
'merge it on the rooted object',
[
wmlName(),
fc.dictionary(wmlName(), fc.record({ translatable: fc.boolean(), value: fc.string() })),
fc.dictionary(wmlName(), fc.record({ translatable: fc.boolean(), value: fc.string() })),
],
(tagName, firstAttributes, secondAttributes) => {
const parsed: WmlParsed = [
[tagName, firstAttributes],
[tagName, secondAttributes],
]
const rooted = rootByTagName(parsed)
expect(rooted).toStrictEqual({
[tagName]: [firstAttributes, secondAttributes],
})
}
)
})
describe('with different names', () => {
testProp(
'includes both on the rooted object',
[
fc.tuple(wmlName(), wmlName()).filter(([first, second]) => first !== second),
fc.dictionary(wmlName(), fc.record({ translatable: fc.boolean(), value: fc.string() })),
fc.dictionary(wmlName(), fc.record({ translatable: fc.boolean(), value: fc.string() })),
],
([firstTagName, secondTagName], firstAttributes, secondAttributes) => {
const parsed: WmlParsed = [
[firstTagName, firstAttributes],
[secondTagName, secondAttributes],
]
const rooted = rootByTagName(parsed)
expect(rooted).toStrictEqual({
[firstTagName]: [firstAttributes],
[secondTagName]: [secondAttributes],
})
}
)
})
})
})

View file

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"sourceMap": true
},
"exclude": [
"./tests"
]
}

View file

@ -0,0 +1,6 @@
{
"extends": "./tsconfig.json",
"exclude": [
"./tests"
]
}

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2019",
"module": "commonjs",
"importsNotUsedAsValues": "error",
"strict": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "node",
"esModuleInterop": true,
"resolveJsonModule": true,
"noImplicitAny": false,
"outDir": "./build",
},
}