fimfarchive/tests/test_mappers.py

569 lines
14 KiB
Python
Raw Normal View History

2016-12-27 20:24:29 +01:00
"""
Mapper tests.
"""
#
# Fimfarchive, preserves stories from Fimfiction.
# Copyright (C) 2019 Joakim Soderlund
2016-12-27 20:24:29 +01:00
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
2017-01-07 22:55:12 +01:00
import os
2019-06-08 17:21:04 +02:00
from io import BytesIO
2019-03-09 21:39:56 +01:00
from pathlib import Path
from typing import Any, Dict
2017-01-15 18:07:32 +01:00
from unittest.mock import patch, MagicMock, PropertyMock
2019-06-08 17:21:04 +02:00
from zipfile import ZipFile
2017-01-07 22:55:12 +01:00
2016-12-27 20:24:29 +01:00
import pytest
2017-01-15 18:07:32 +01:00
from fimfarchive.exceptions import InvalidStoryError
2018-10-13 19:59:50 +02:00
from fimfarchive.flavors import DataFormat, MetaFormat
2017-11-10 23:38:49 +01:00
from fimfarchive.mappers import (
2019-06-08 17:21:04 +02:00
DataFormatMapper, MetaFormatMapper, StaticMapper,
StoryDateMapper, StoryPathMapper, StorySlugMapper,
2017-11-10 23:38:49 +01:00
)
2017-01-15 18:07:32 +01:00
from fimfarchive.stories import Story
CHAPTERS = 'chapters'
MODIFIED = 'date_modified'
2016-12-27 20:24:29 +01:00
class TestStaticMapper:
"""
StaticMapper tests.
"""
@pytest.fixture
def value(self):
"""
Returns a unique instance.
"""
return object()
def test_value(self, story, value):
2016-12-27 20:24:29 +01:00
"""
Tests returns the supplied value.
"""
mapper = StaticMapper(value)
assert mapper(story) is value
2017-01-07 22:55:12 +01:00
2017-01-15 18:07:32 +01:00
class TestStoryDateMapper:
"""
StoryDateMapper tests.
"""
@pytest.fixture
def mapper(self):
"""
Returns a new StoryDateMapper.
"""
return StoryDateMapper()
def merge(self, story, meta):
"""
Returns a cloned story containing the supplied meta.
"""
return Story(
key=story.key,
fetcher=None,
meta=meta,
data=story.data,
flavors=story.flavors,
)
def test_missing_story(self, mapper):
"""
Tests `None` is returned when story is `None`.
"""
assert mapper(None) is None
def test_invalid_story(self, mapper, story):
"""
Tests `None` is returned when `InvalidStoryError` is raised.
"""
with patch.object(Story, 'meta', new_callable=PropertyMock) as m:
m.side_effect = InvalidStoryError
assert mapper(story) is None
assert m.called_once_with(story)
def test_empty_meta(self, mapper, story):
"""
Tests `None` is returned when meta is empty.
"""
2017-01-29 00:06:15 +01:00
story = story.merge(meta=dict())
2017-01-15 18:07:32 +01:00
assert mapper(story) is None
def test_meta_without_dates(self, mapper, story):
"""
Tests `None` is returned when meta contains no dates.
"""
2017-11-10 23:38:49 +01:00
meta: Dict[str, Any] = {
2017-01-15 18:07:32 +01:00
CHAPTERS: [
dict(),
dict(),
dict(),
],
}
2017-01-29 00:06:15 +01:00
story = story.merge(meta=meta)
2017-01-15 18:07:32 +01:00
assert mapper(story) is None
def test_meta_without_chapters(self, mapper, story):
"""
Tests timestamp is returned when no chapters are present.
"""
meta = {
MODIFIED: 5,
}
2017-01-29 00:06:15 +01:00
story = story.merge(meta=meta)
2017-01-15 18:07:32 +01:00
assert mapper(story).timestamp == 5
2017-01-15 18:07:32 +01:00
def test_meta_with_none_chapters(self, mapper, story):
"""
Tests timestamp is returned when chapters is `None`.
"""
meta = {
MODIFIED: 5,
CHAPTERS: None,
}
2017-01-29 00:06:15 +01:00
story = story.merge(meta=meta)
2017-01-15 18:07:32 +01:00
assert mapper(story).timestamp == 5
2017-01-15 18:07:32 +01:00
def test_meta_with_empty_chapters(self, mapper, story):
"""
Tests timestamp is returned when chapters is empty.
"""
meta = {
MODIFIED: 5,
CHAPTERS: [],
}
2017-01-29 00:06:15 +01:00
story = story.merge(meta=meta)
2017-01-15 18:07:32 +01:00
assert mapper(story).timestamp == 5
2017-01-15 18:07:32 +01:00
def test_meta_with_only_chapter_dates(self, mapper, story):
"""
Tests timestamp is returned when only chapter dates are present.
"""
meta = {
CHAPTERS: [
{MODIFIED: 3},
{MODIFIED: 5},
{MODIFIED: 4},
],
}
2017-01-29 00:06:15 +01:00
story = story.merge(meta=meta)
2017-01-15 18:07:32 +01:00
assert mapper(story).timestamp == 5
2017-01-15 18:07:32 +01:00
def test_meta_with_only_story_date(self, mapper, story):
"""
Tests timestamp is returned when only story date is present.
"""
meta = {
MODIFIED: 5,
CHAPTERS: [
dict(),
dict(),
dict(),
],
}
2017-01-29 00:06:15 +01:00
story = story.merge(meta=meta)
2017-01-15 18:07:32 +01:00
assert mapper(story).timestamp == 5
2017-01-15 18:07:32 +01:00
def test_meta_with_latest_chapter_date(self, mapper, story):
"""
Tests latests timestamp is returned when in chapter dates.
"""
meta = {
MODIFIED: 3,
CHAPTERS: [
{MODIFIED: 1},
{MODIFIED: 5},
{MODIFIED: 3},
],
}
2017-01-29 00:06:15 +01:00
story = story.merge(meta=meta)
2017-01-15 18:07:32 +01:00
assert mapper(story).timestamp == 5
2017-01-15 18:07:32 +01:00
def test_meta_with_latest_story_date(self, mapper, story):
"""
Tests latest timestamp is returned when in story date.
"""
meta = {
MODIFIED: 5,
CHAPTERS: [
{MODIFIED: 1},
{MODIFIED: 3},
{MODIFIED: 2},
],
}
2017-01-29 00:06:15 +01:00
story = story.merge(meta=meta)
2017-01-15 18:07:32 +01:00
assert mapper(story).timestamp == 5
2017-01-15 18:07:32 +01:00
def test_meta_with_both_latest(self, mapper, story):
"""
Tests latest timestamp is returned when in both.
"""
meta = {
MODIFIED: 5,
CHAPTERS: [
{MODIFIED: 1},
{MODIFIED: 5},
{MODIFIED: 3},
],
}
2017-01-29 00:06:15 +01:00
story = story.merge(meta=meta)
2017-01-15 18:07:32 +01:00
assert mapper(story).timestamp == 5
2017-01-15 18:07:32 +01:00
2017-01-07 22:55:12 +01:00
class TestStoryPathMapper:
"""
StoryPathMapper tests.
"""
def test_joins_paths(self, story):
"""
Tests returns directory joined with story key.
"""
directory = os.path.join('some', 'directory')
path = os.path.join(directory, str(story.key))
mapper = StoryPathMapper(directory)
2019-03-09 21:39:56 +01:00
assert mapper(story) == Path(path)
2017-01-07 22:55:12 +01:00
def test_casts_values(self, tmpdir, story):
"""
Tests casts all values to string when joining.
"""
2019-03-09 21:39:56 +01:00
mapper = StoryPathMapper('dir')
2017-01-07 22:55:12 +01:00
story.key = MagicMock()
story.key.__str__.return_value = 'key'
2019-03-09 21:39:56 +01:00
assert mapper(story) == Path('dir', 'key')
2017-01-07 22:55:12 +01:00
assert story.key.__str__.called_once_with()
2017-11-10 23:38:49 +01:00
2018-10-13 19:59:50 +02:00
class TestStorySlugMapper:
"""
StorySlugMapper tests.
"""
@pytest.fixture
def mapper(self) -> StorySlugMapper:
"""
Returns a mapper instance.
"""
return StorySlugMapper()
@pytest.fixture
def story(self, story: Story) -> Story:
"""
Returns a story instance.
"""
meta = {
'id': 1337,
'title': 'title',
'author': {
'id': 42,
'name': 'name',
},
}
return story.merge(
meta=meta,
flavors=[DataFormat.EPUB]
)
def test_mapping(self, mapper, story):
"""
Tests a simple slug mapping.
"""
assert mapper(story) == 'epub/n/name-42/title-1337.epub'
def test_custom_mapping(self, story):
"""
Tests a slug mapping with a custom template.
"""
mapper = StorySlugMapper('{story}.{extension}')
assert mapper(story) == 'title-1337.epub'
@pytest.mark.parametrize('text,result', (
('Project Sunflower: Harmony', 'project_sunflower_harmony'),
('The Enchanted Library', 'the_enchanted_library'),
('Hurricane\'s Way', 'hurricanes_way'),
('Sharers\' Day', 'sharers_day'),
('Paca ' * 32, ('paca_' * 22)[:-1]),
('Paca' * 32, ''),
(None, 'none'),
(' ', ''),
(' ', ''),
('', ''),
))
def test_slugify(self, mapper, text, result):
"""
Tests creating a slug for a title.
"""
assert mapper.slugify(text) == result
@pytest.mark.parametrize('key,slug,result', (
(16, 'slug', 'slug-16'),
(32, None, '32'),
(64, '', '64'),
))
def test_join(self, mapper, key, slug, result):
"""
Tests joining a slug with a key.
"""
assert mapper.join(key, slug) == result
def test_join_with_negative_key(self, mapper):
"""
Tests `ValueError` is raised when joining a negative key.
"""
with pytest.raises(ValueError):
mapper.join('-1', 'slug')
@pytest.mark.parametrize('slug,result', (
('alpaca', 'a'),
('pony', 'p'),
('42', '_'),
(' ', '_'),
(' ', '_'),
('', '_'),
))
def test_group(self, mapper, slug, result):
"""
Tests grouping of a slug.
"""
assert mapper.group(slug) == result
@pytest.mark.parametrize('flavors,result', (
({MetaFormat.BETA, DataFormat.EPUB}, 'epub'),
({DataFormat.EPUB}, 'epub'),
({DataFormat.HTML}, 'html'),
({MetaFormat.BETA}, 'data'),
({}, 'data'),
))
def test_classify(self, mapper, story, flavors, result):
"""
Tests classify with a story.
"""
story = story.merge(flavors=flavors)
assert mapper.classify(story) == result
def test_map_with_long_template(self, story):
"""
Tests `ValueError` is raised when result is too long.
"""
mapper = StorySlugMapper('paca' * 256)
with pytest.raises(ValueError):
mapper(story)
2017-11-10 23:38:49 +01:00
class TestMetaFormatMapper:
"""
MetaFormatMapper tests.
"""
@pytest.fixture
def mapper(self):
"""
Returns a meta format mapper instance.
"""
return MetaFormatMapper()
@pytest.fixture(params=['likes', 'dislikes', 'words'])
def alpha(self, request):
"""
Returns an alpha meta key.
"""
return request.param
@pytest.fixture(params=['num_likes', 'num_dislikes', 'num_words'])
def beta(self, request):
"""
Returns a beta meta key.
"""
return request.param
def merge(self, story, *keys):
"""
Returns a story containing the requested meta keys.
"""
meta = {key: i for i, key in enumerate(keys, 1)}
return story.merge(meta=meta)
def test_alpha_format(self, mapper, story, alpha):
"""
Tests alpha meta format is detected.
"""
story = self.merge(story, alpha, 'misc')
assert mapper(story) is MetaFormat.ALPHA
2017-11-10 23:38:49 +01:00
def test_beta_format(self, mapper, story, beta):
"""
Tests beta meta format is detected.
"""
story = self.merge(story, beta, 'misc')
assert mapper(story) is MetaFormat.BETA
2017-11-10 23:38:49 +01:00
def test_conflict(self, mapper, story, alpha, beta):
"""
Tests None is returned for conflicting meta keys.
"""
story = self.merge(story, alpha, beta)
assert mapper(story) is None
def test_existing_flavor(self, mapper, story, beta):
"""
Tests existing flavor takes precedence over meta.
"""
story = story.merge(flavors=[MetaFormat.ALPHA])
story = self.merge(story, beta, 'misc')
assert mapper(story) is MetaFormat.ALPHA
2019-06-08 17:21:04 +02:00
class TestDataFormatMapper:
"""
DataFormatMapper tests.
"""
@pytest.fixture
def mapper(self):
"""
Returns a data format mapper instance.
"""
return DataFormatMapper()
def zip(self, names) -> bytes:
"""
Returns a populated ZIP-file as bytes.
"""
data = BytesIO()
with ZipFile(data, 'w') as zobj:
for name in names:
zobj.writestr(name, name)
return data.getvalue()
@pytest.mark.parametrize('data', [
b'{}',
b'{"id": 42}',
b'{"id": 42}\n',
])
def test_json_mapping(self, mapper, story, data):
"""
Tests detection of JSON data format.
"""
story = story.merge(data=data, flavors=[])
assert DataFormat.JSON is mapper(story)
@pytest.mark.parametrize('files', [
['mimetype', 'book.ncx', 'book.opf'],
['mimetype', 'book.opf', 'book.ncx', 'Chapter1.html'],
])
def test_fpub_mapping(self, mapper, story, files):
"""
Tests detection of FPUB data format.
"""
story = story.merge(data=self.zip(files), flavors=[])
assert DataFormat.FPUB is mapper(story)
@pytest.mark.parametrize('files', [
['mimetype', 'content.opf', 'toc.ncx'],
['mimetype', 'toc.ncx', 'content.opf', 'Chapter1.html'],
])
def test_epub_mapping(self, mapper, story, files):
"""
Tests detection of EPUB data format.
"""
story = story.merge(data=self.zip(files), flavors=[])
assert DataFormat.EPUB is mapper(story)
@pytest.mark.parametrize('fmt', [
DataFormat.EPUB,
DataFormat.JSON,
])
def test_included_mapping(self, mapper, story, fmt):
"""
Tests detection of included flavor.
"""
story = story.merge(flavors=[fmt])
assert fmt is mapper(story)
@pytest.mark.parametrize('data', [
b'',
b'P',
b'PK',
b'PK\x03',
b'PK\x03\x03',
])
def test_unknown_raw_mapping(self, mapper, story, data):
"""
Tests unknown raw data returns no flavor.
"""
story = story.merge(data=data, flavors=[])
assert None is mapper(story)
@pytest.mark.parametrize('files', [
[],
['alpaca.jpg'],
['book.opf', 'book.ncx'],
['mimetype', 'book.ncx'],
['content.opf', 'tox.ncx', 'Chapter1.html'],
])
def test_unknown_zip_mapping(self, mapper, story, files):
"""
Tests unknown ZIP data returns no flavor.
"""
story = story.merge(data=self.zip(files), flavors=[])
assert None is mapper(story)