18 Commits

Author SHA1 Message Date
990efee900 test: use sql script instead of raw database file 2024-09-27 23:55:41 +08:00
10c869846c refactor: chart info database importer 2024-09-27 23:46:40 +08:00
d97ed91631 chore: update ruff version 2024-09-27 23:45:46 +08:00
5e996d35d2 chore!: remove legacy external arcaea parsers 2024-09-27 23:12:58 +08:00
bfa1472b5c refactor: arcaea online play results importer 2024-09-27 23:10:48 +08:00
bb163ad78d chore: rename database external arcaea parsers 2024-09-27 18:35:20 +08:00
864f524e68 ci: tox pytest command 2024-08-06 12:33:32 +08:00
03696650ea fix: py3.8 compatibility 2024-08-06 12:32:52 +08:00
4e799034d7 fix: linter warning PLR2004 2024-08-06 12:32:43 +08:00
b8136bf25f chore: fix ci 2024-08-06 00:31:09 +08:00
86d7a86700 chore: fix imports 2024-08-06 00:24:08 +08:00
a32453b989 refactor: st3 parser (importer) 2024-08-06 00:23:31 +08:00
d52d234adc refactor: external importers
* packlist & songlist importer
2024-08-05 15:07:55 +08:00
88201e2ca4 wip: v5 database models and tests 2024-06-20 02:30:37 +08:00
43be27bd4a fix: ruff F401 warnings 2024-05-22 02:32:34 +08:00
1c114816c0 ci: sync changes in master branch 2024-05-22 00:29:41 +08:00
f6e5f45579 test: refactor legacy tests 2024-05-21 21:07:09 +08:00
a27afca8a7 test: conftest database clean-up 2024-05-21 21:01:35 +08:00
59 changed files with 1919 additions and 626 deletions

41
.github/workflows/main.yml vendored Normal file
View File

@ -0,0 +1,41 @@
name: test & lint
on:
push:
branches:
- '*'
pull_request:
types: [opened, reopened]
workflow_dispatch:
jobs:
pytest:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dev dependencies
run: 'pip install .[dev]'
- name: Run tests
run: 'python -m pytest -v'
ruff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dev dependencies
run: 'pip install .[dev]'
- name: Run linter
run: 'ruff check'

View File

@ -1,23 +0,0 @@
name: Run tests
on:
push:
branches:
- 'master'
pull_request:
types: [opened, reopened]
workflow_dispatch:
jobs:
pytest:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.8', '3.9', '3.10', '3.11']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- run: 'pip install -r requirements.dev.txt .'
- run: 'pytest -v'

View File

@ -4,11 +4,10 @@ repos:
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/psf/black
rev: 23.1.0
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.8
hooks:
- id: black
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
hooks:
- id: isort
- id: ruff
args: ["--fix"]
- id: ruff-format

View File

@ -9,33 +9,45 @@ authors = [{ name = "283375", email = "log_283375@163.com" }]
description = "Manage your local Arcaea score database."
readme = "README.md"
requires-python = ">=3.8"
dependencies = [
"SQLAlchemy==2.0.20",
"SQLAlchemy-Utils==0.41.1",
]
dependencies = ["SQLAlchemy==2.0.20", "SQLAlchemy-Utils==0.41.1"]
classifiers = [
"Development Status :: 3 - Alpha",
"Programming Language :: Python :: 3",
]
[project.optional-dependencies]
dev = ["ruff~=0.6.8", "pre-commit~=3.3", "pytest~=7.4", "tox~=4.11"]
[project.urls]
"Homepage" = "https://github.com/283375/arcaea-offline"
"Bug Tracker" = "https://github.com/283375/arcaea-offline/issues"
[tool.isort]
profile = "black"
src_paths = ["src/arcaea_offline"]
[tool.pyright]
ignore = ["build/"]
[tool.pylint.main]
jobs = 0
[tool.pylint.logging]
disable = [
"missing-module-docstring",
"missing-class-docstring",
"missing-function-docstring",
"not-callable", # false positive to sqlalchemy `func.*`, remove this when pylint-dev/pylint(#8138) closed
[tool.ruff.lint]
# Full list: https://docs.astral.sh/ruff/rules
select = [
"E", # pycodestyle (Error)
"W", # pycodestyle (Warning)
"F", # pyflakes
"I", # isort
"PL", # pylint
"N", # pep8-naming
"FBT", # flake8-boolean-trap
"A", # flake8-builtins
"DTZ", # flake8-datetimez
"LOG", # flake8-logging
"Q", # flake8-quotes
"G", # flake8-logging-format
"PIE", # flake8-pie
"PT", # flake8-pytest-style
]
ignore = [
"E501", # line-too-long
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = [
"PLR2004", # magic-value-comparison
]

View File

@ -1,6 +1,4 @@
black==23.3.0
isort==5.12.0
pre-commit==3.3.1
pylint==3.0.2
pytest==7.4.3
tox==4.11.3
ruff~=0.6.8
pre-commit~=3.3
pytest~=7.4
tox~=4.11

View File

@ -1,2 +1,3 @@
from . import world
from .play_result import PlayResultCalculators
__all__ = ["PlayResultCalculators"]

View File

@ -7,7 +7,7 @@ from arcaea_offline.constants.play_result import ScoreLowerLimits
class PlayResultCalculators:
@staticmethod
def score_possible_range(notes: int, pure: int, far: int) -> tuple[int, int]:
def score_possible_range(notes: int, pure: int, far: int) -> Tuple[int, int]:
"""
Returns the possible range of score based on the given values.
@ -44,9 +44,9 @@ class PlayResultCalculators:
if score < 0:
raise ValueError("score cannot be negative")
if score >= 10000000:
if score >= ScoreLowerLimits.PM:
return Decimal(2)
if score >= 9800000:
if score >= ScoreLowerLimits.EX:
return Decimal(1) + (Decimal(score - 9800000) / 200000)
return Decimal(score - 9500000) / 300000

View File

@ -9,3 +9,17 @@ from .partners import (
MayaPartnerBonus,
MithraTerceraPartnerBonus,
)
__all__ = [
"AmaneBelowExPartnerBonus",
"AwakenedEtoPartnerBonus",
"AwakenedIlithPartnerBonus",
"AwakenedLunaPartnerBonus",
"LegacyMapStepBooster",
"MayaPartnerBonus",
"MemoriesStepBooster",
"MithraTerceraPartnerBonus",
"PartnerBonus",
"WorldMainMapCalculators",
"WorldPlayResult",
]

View File

@ -3,6 +3,7 @@ from dataclasses import dataclass
@dataclass(frozen=True)
class ScoreLowerLimits:
PM = 10000000
EX_PLUS = 9900000
EX = 9800000
AA = 9500000

View File

@ -1 +1,3 @@
from .db import Database
__all__ = ["Database"]

View File

@ -6,11 +6,8 @@ from sqlalchemy import Engine, func, inspect, select
from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, sessionmaker
from arcaea_offline.external.arcsong.arcsong_json import ArcSongJsonBuilder
from arcaea_offline.external.exports import (
ArcaeaOfflineDEFV2_Score,
ScoreExport,
exporters,
)
from arcaea_offline.external.exports import exporters
from arcaea_offline.external.exports.types import ArcaeaOfflineDEFV2_Score, ScoreExport
from arcaea_offline.singleton import Singleton
from .models.v4.config import ConfigBase, Property

View File

@ -19,3 +19,24 @@ from .songs import (
SongsBase,
SongsViewBase,
)
__all__ = [
"CalculatedPotential",
"Chart",
"ChartInfo",
"ConfigBase",
"Difficulty",
"DifficultyLocalized",
"Pack",
"PackLocalized",
"Property",
"Score",
"ScoreBest",
"ScoreCalculated",
"ScoresBase",
"ScoresViewBase",
"Song",
"SongLocalized",
"SongsBase",
"SongsViewBase",
]

View File

@ -10,12 +10,12 @@ from .common import ReprHelper
from .songs import ChartInfo, Difficulty
__all__ = [
"ScoresBase",
"Score",
"ScoresViewBase",
"ScoreCalculated",
"ScoreBest",
"CalculatedPotential",
"Score",
"ScoreBest",
"ScoreCalculated",
"ScoresBase",
"ScoresViewBase",
]

View File

@ -9,16 +9,16 @@ from sqlalchemy_utils import create_view
from .common import ReprHelper
__all__ = [
"SongsBase",
"Chart",
"ChartInfo",
"Difficulty",
"DifficultyLocalized",
"Pack",
"PackLocalized",
"Song",
"SongLocalized",
"Difficulty",
"DifficultyLocalized",
"ChartInfo",
"SongsBase",
"SongsViewBase",
"Chart",
]

View File

@ -0,0 +1,38 @@
from .arcaea import (
Chart,
ChartInfo,
Difficulty,
DifficultyLocalized,
Pack,
PackLocalized,
Song,
SongLocalized,
SongSearchWord,
)
from .base import ModelsV5Base, ModelsV5ViewBase
from .config import Property
from .play_results import (
CalculatedPotential,
PlayResult,
PlayResultBest,
PlayResultCalculated,
)
__all__ = [
"CalculatedPotential",
"Chart",
"ChartInfo",
"Difficulty",
"DifficultyLocalized",
"ModelsV5Base",
"ModelsV5ViewBase",
"Pack",
"PackLocalized",
"PlayResult",
"PlayResultBest",
"PlayResultCalculated",
"Property",
"Song",
"SongLocalized",
"SongSearchWord",
]

View File

@ -0,0 +1,275 @@
from typing import List, Optional
from sqlalchemy import ForeignKey, and_, func, select
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy_utils import create_view
from arcaea_offline.constants.enums import ArcaeaRatingClass, ArcaeaSongSide
from .base import ModelsV5Base, ModelsV5ViewBase, ReprHelper
__all__ = [
"Chart",
"ChartInfo",
"Difficulty",
"DifficultyLocalized",
"Pack",
"PackLocalized",
"Song",
"SongLocalized",
]
class Pack(ModelsV5Base, ReprHelper):
__tablename__ = "packs"
id: Mapped[str] = mapped_column(primary_key=True)
name: Mapped[str]
description: Mapped[Optional[str]]
songs: Mapped[List["Song"]] = relationship(back_populates="pack", viewonly=True)
localized_objects: Mapped[List["PackLocalized"]] = relationship(
back_populates="parent", viewonly=True
)
class PackLocalized(ModelsV5Base, ReprHelper):
__tablename__ = "packs_localized"
pkid: Mapped[int] = mapped_column(primary_key=True)
id: Mapped[str] = mapped_column(
ForeignKey(Pack.id, onupdate="CASCADE", ondelete="NO ACTION")
)
lang: Mapped[str]
name: Mapped[Optional[str]]
description: Mapped[Optional[str]]
parent: Mapped[Pack] = relationship(viewonly=True)
class Song(ModelsV5Base, ReprHelper):
__tablename__ = "songs"
idx: Mapped[int]
id: Mapped[str] = mapped_column(primary_key=True)
title: Mapped[str]
artist: Mapped[str]
pack_id: Mapped[str] = mapped_column(
ForeignKey(Pack.id, onupdate="CASCADE", ondelete="NO ACTION")
)
bpm: Mapped[Optional[str]]
bpm_base: Mapped[Optional[float]]
audio_preview: Mapped[Optional[int]]
audio_preview_end: Mapped[Optional[int]]
side: Mapped[Optional[ArcaeaSongSide]]
version: Mapped[Optional[str]]
date: Mapped[Optional[int]]
bg: Mapped[Optional[str]]
bg_inverse: Mapped[Optional[str]]
bg_day: Mapped[Optional[str]]
bg_night: Mapped[Optional[str]]
source: Mapped[Optional[str]]
source_copyright: Mapped[Optional[str]]
pack: Mapped[Pack] = relationship(viewonly=True)
difficulties: Mapped[List["Difficulty"]] = relationship(
back_populates="song", viewonly=True
)
localized_objects: Mapped[List["SongLocalized"]] = relationship(
back_populates="parent", viewonly=True
)
@property
def charts_info(self):
return [d.chart_info for d in self.difficulties if d.chart_info is not None]
class SongLocalized(ModelsV5Base, ReprHelper):
__tablename__ = "songs_localized"
pkid: Mapped[int] = mapped_column(primary_key=True)
id: Mapped[str] = mapped_column(
ForeignKey(Song.id, onupdate="CASCADE", ondelete="NO ACTION")
)
lang: Mapped[str]
title: Mapped[Optional[str]]
source: Mapped[Optional[str]]
parent: Mapped[Song] = relationship(
back_populates="localized_objects", viewonly=True
)
class SongSearchWord(ModelsV5Base, ReprHelper):
__tablename__ = "songs_search_words"
pkid: Mapped[int] = mapped_column(primary_key=True)
id: Mapped[str] = mapped_column(
ForeignKey(Song.id, onupdate="CASCADE", ondelete="NO ACTION")
)
lang: Mapped[str]
type: Mapped[int] = mapped_column(comment="1: title, 2: artist")
value: Mapped[str]
class Difficulty(ModelsV5Base, ReprHelper):
__tablename__ = "difficulties"
song_id: Mapped[str] = mapped_column(
ForeignKey(Song.id, onupdate="CASCADE", ondelete="NO ACTION"),
primary_key=True,
)
rating_class: Mapped[ArcaeaRatingClass] = mapped_column(primary_key=True)
rating: Mapped[int]
rating_plus: Mapped[bool]
chart_designer: Mapped[Optional[str]]
jacket_desginer: Mapped[Optional[str]]
audio_override: Mapped[bool] = mapped_column(default=False)
jacket_override: Mapped[bool] = mapped_column(default=False)
jacket_night: Mapped[Optional[str]]
title: Mapped[Optional[str]]
artist: Mapped[Optional[str]]
bg: Mapped[Optional[str]]
bg_inverse: Mapped[Optional[str]]
bpm: Mapped[Optional[str]]
bpm_base: Mapped[Optional[float]]
version: Mapped[Optional[str]]
date: Mapped[Optional[int]]
song: Mapped[Song] = relationship(back_populates="difficulties", viewonly=True)
chart_info: Mapped[Optional["ChartInfo"]] = relationship(
primaryjoin=(
"and_(Difficulty.song_id==ChartInfo.song_id, "
"Difficulty.rating_class==ChartInfo.rating_class)"
),
viewonly=True,
)
localized_objects: Mapped[List["DifficultyLocalized"]] = relationship(
primaryjoin=(
"and_(Difficulty.song_id==DifficultyLocalized.song_id, "
"Difficulty.rating_class==DifficultyLocalized.rating_class)"
),
viewonly=True,
)
class DifficultyLocalized(ModelsV5Base, ReprHelper):
__tablename__ = "difficulties_localized"
pkid: Mapped[int] = mapped_column(primary_key=True)
song_id: Mapped[str] = mapped_column(
ForeignKey(Difficulty.song_id, onupdate="CASCADE", ondelete="NO ACTION")
)
rating_class: Mapped[ArcaeaRatingClass] = mapped_column(
ForeignKey(Difficulty.rating_class, onupdate="CASCADE", ondelete="NO ACTION")
)
lang: Mapped[str]
title: Mapped[Optional[str]]
artist: Mapped[Optional[str]]
parent: Mapped[Difficulty] = relationship(
primaryjoin=and_(
Difficulty.song_id == song_id, Difficulty.rating_class == rating_class
),
viewonly=True,
)
class ChartInfo(ModelsV5Base, ReprHelper):
__tablename__ = "charts_info"
song_id: Mapped[str] = mapped_column(
ForeignKey(Difficulty.song_id, onupdate="CASCADE", ondelete="NO ACTION"),
primary_key=True,
)
rating_class: Mapped[ArcaeaRatingClass] = mapped_column(
ForeignKey(Difficulty.rating_class, onupdate="CASCADE", ondelete="NO ACTION"),
primary_key=True,
)
constant: Mapped[int] = mapped_column(
comment="real_constant * 10. For example, Crimson Throne [FTR] is 10.4, then store 104."
)
notes: Mapped[Optional[int]]
difficulty: Mapped[Difficulty] = relationship(
primaryjoin=and_(
Difficulty.song_id == song_id, Difficulty.rating_class == rating_class
),
viewonly=True,
)
class Chart(ModelsV5ViewBase, ReprHelper):
__tablename__ = "charts"
song_idx: Mapped[int]
song_id: Mapped[str]
rating_class: Mapped[ArcaeaRatingClass]
rating: Mapped[int]
rating_plus: Mapped[bool]
title: Mapped[str]
artist: Mapped[str]
pack_id: Mapped[str]
bpm: Mapped[Optional[str]]
bpm_base: Mapped[Optional[float]]
audio_preview: Mapped[Optional[int]]
audio_preview_end: Mapped[Optional[int]]
side: Mapped[Optional[int]]
version: Mapped[Optional[str]]
date: Mapped[Optional[int]]
bg: Mapped[Optional[str]]
bg_inverse: Mapped[Optional[str]]
bg_day: Mapped[Optional[str]]
bg_night: Mapped[Optional[str]]
source: Mapped[Optional[str]]
source_copyright: Mapped[Optional[str]]
chart_designer: Mapped[Optional[str]]
jacket_desginer: Mapped[Optional[str]]
audio_override: Mapped[bool]
jacket_override: Mapped[bool]
jacket_night: Mapped[Optional[str]]
constant: Mapped[int]
notes: Mapped[Optional[int]]
__table__ = create_view(
name=__tablename__,
selectable=select(
Song.idx.label("song_idx"),
Difficulty.song_id,
Difficulty.rating_class,
Difficulty.rating,
Difficulty.rating_plus,
func.coalesce(Difficulty.title, Song.title).label("title"),
func.coalesce(Difficulty.artist, Song.artist).label("artist"),
Song.pack_id,
func.coalesce(Difficulty.bpm, Song.bpm).label("bpm"),
func.coalesce(Difficulty.bpm_base, Song.bpm_base).label("bpm_base"),
Song.audio_preview,
Song.audio_preview_end,
Song.side,
func.coalesce(Difficulty.version, Song.version).label("version"),
func.coalesce(Difficulty.date, Song.date).label("date"),
func.coalesce(Difficulty.bg, Song.bg).label("bg"),
func.coalesce(Difficulty.bg_inverse, Song.bg_inverse).label("bg_inverse"),
Song.bg_day,
Song.bg_night,
Song.source,
Song.source_copyright,
Difficulty.chart_designer,
Difficulty.jacket_desginer,
Difficulty.audio_override,
Difficulty.jacket_override,
Difficulty.jacket_night,
ChartInfo.constant,
ChartInfo.notes,
)
.select_from(Difficulty)
.join(
ChartInfo,
(Difficulty.song_id == ChartInfo.song_id)
& (Difficulty.rating_class == ChartInfo.rating_class),
)
.join(Song, Difficulty.song_id == Song.id),
metadata=ModelsV5ViewBase.metadata,
cascade_on_drop=False,
)

View File

@ -0,0 +1,62 @@
from datetime import datetime
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm.exc import DetachedInstanceError
from arcaea_offline.constants.enums import (
ArcaeaPlayResultClearType,
ArcaeaPlayResultModifier,
ArcaeaRatingClass,
ArcaeaSongSide,
)
from .._custom_types import DbIntEnum, TZDateTime
TYPE_ANNOTATION_MAP = {
datetime: TZDateTime,
ArcaeaRatingClass: DbIntEnum(ArcaeaRatingClass),
ArcaeaSongSide: DbIntEnum(ArcaeaSongSide),
ArcaeaPlayResultClearType: DbIntEnum(ArcaeaPlayResultClearType),
ArcaeaPlayResultModifier: DbIntEnum(ArcaeaPlayResultModifier),
}
class ModelsV5Base(DeclarativeBase):
type_annotation_map = TYPE_ANNOTATION_MAP
class ModelsV5ViewBase(DeclarativeBase):
type_annotation_map = TYPE_ANNOTATION_MAP
class ReprHelper:
# pylint: disable=no-member
def _repr(self, **kwargs) -> str:
"""
Helper for __repr__
https://stackoverflow.com/a/55749579/16484891
CC BY-SA 4.0
"""
field_strings = []
at_least_one_attached_attribute = False
for key, field in kwargs.items():
try:
field_strings.append(f"{key}={field!r}")
except DetachedInstanceError:
field_strings.append(f"{key}=DetachedInstanceError")
else:
at_least_one_attached_attribute = True
if at_least_one_attached_attribute:
return f"<{self.__class__.__name__}({', '.join(field_strings)})>"
return f"<{self.__class__.__name__} {id(self)}>"
def __repr__(self):
if isinstance(self, DeclarativeBase):
return self._repr(
**{c.key: getattr(self, c.key) for c in self.__table__.columns}
)
return super().__repr__()

View File

@ -0,0 +1,12 @@
from sqlalchemy.orm import Mapped, mapped_column
from .base import ModelsV5Base, ReprHelper
__all__ = ["Property"]
class Property(ModelsV5Base, ReprHelper):
__tablename__ = "properties"
key: Mapped[str] = mapped_column(primary_key=True)
value: Mapped[str] = mapped_column()

View File

@ -0,0 +1,197 @@
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import ForeignKey, and_, case, func, inspect, select, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy_utils import create_view
from arcaea_offline.constants.enums import (
ArcaeaPlayResultClearType,
ArcaeaPlayResultModifier,
ArcaeaRatingClass,
)
from .arcaea import ChartInfo, Difficulty
from .base import ModelsV5Base, ModelsV5ViewBase, ReprHelper
__all__ = [
"CalculatedPotential",
"PlayResult",
"PlayResultBest",
"PlayResultCalculated",
]
class PlayResult(ModelsV5Base, ReprHelper):
__tablename__ = "play_results"
id: Mapped[int] = mapped_column(autoincrement=True, primary_key=True)
song_id: Mapped[str] = mapped_column(
ForeignKey(Difficulty.song_id, onupdate="CASCADE", ondelete="NO ACTION"),
index=True,
)
rating_class: Mapped[ArcaeaRatingClass] = mapped_column(
ForeignKey(Difficulty.rating_class, onupdate="CASCADE", ondelete="NO ACTION"),
index=True,
)
score: Mapped[int]
pure: Mapped[Optional[int]]
far: Mapped[Optional[int]]
lost: Mapped[Optional[int]]
date: Mapped[Optional[datetime]] = mapped_column(
default=lambda: datetime.now(timezone.utc)
)
max_recall: Mapped[Optional[int]]
modifier: Mapped[Optional[ArcaeaPlayResultModifier]]
clear_type: Mapped[Optional[ArcaeaPlayResultClearType]]
comment: Mapped[Optional[str]]
difficulty: Mapped[Difficulty] = relationship(
primaryjoin=and_(
song_id == Difficulty.song_id,
rating_class == Difficulty.rating_class,
),
viewonly=True,
)
# How to create an SQL View with SQLAlchemy?
# https://stackoverflow.com/a/53253105/16484891
# CC BY-SA 4.0
class PlayResultCalculated(ModelsV5ViewBase, ReprHelper):
__tablename__ = "play_results_calculated"
id: Mapped[int]
song_id: Mapped[str]
rating_class: Mapped[ArcaeaRatingClass]
score: Mapped[int]
pure: Mapped[Optional[int]]
shiny_pure: Mapped[Optional[int]]
far: Mapped[Optional[int]]
lost: Mapped[Optional[int]]
date: Mapped[Optional[datetime]]
max_recall: Mapped[Optional[int]]
modifier: Mapped[Optional[ArcaeaPlayResultModifier]]
clear_type: Mapped[Optional[ArcaeaPlayResultClearType]]
potential: Mapped[float]
comment: Mapped[Optional[str]]
__table__ = create_view(
name=__tablename__,
selectable=select(
PlayResult.id,
Difficulty.song_id,
Difficulty.rating_class,
PlayResult.score,
PlayResult.pure,
(
case(
(
(
ChartInfo.notes.is_not(None)
& PlayResult.pure.is_not(None)
& PlayResult.far.is_not(None)
& (ChartInfo.notes != 0)
),
PlayResult.score
- func.floor(
(PlayResult.pure * 10000000.0 / ChartInfo.notes)
+ (PlayResult.far * 0.5 * 10000000.0 / ChartInfo.notes)
),
),
else_=text("NULL"),
)
).label("shiny_pure"),
PlayResult.far,
PlayResult.lost,
PlayResult.date,
PlayResult.max_recall,
PlayResult.modifier,
PlayResult.clear_type,
case(
(PlayResult.score >= 10000000, ChartInfo.constant / 10.0 + 2), # noqa: PLR2004
(
PlayResult.score >= 9800000, # noqa: PLR2004
ChartInfo.constant / 10.0
+ 1
+ (PlayResult.score - 9800000) / 200000.0,
),
else_=func.max(
(ChartInfo.constant / 10.0)
+ (PlayResult.score - 9500000) / 300000.0,
0,
),
).label("potential"),
PlayResult.comment,
)
.select_from(Difficulty)
.join(
ChartInfo,
(Difficulty.song_id == ChartInfo.song_id)
& (Difficulty.rating_class == ChartInfo.rating_class),
)
.join(
PlayResult,
(Difficulty.song_id == PlayResult.song_id)
& (Difficulty.rating_class == PlayResult.rating_class),
),
metadata=ModelsV5ViewBase.metadata,
cascade_on_drop=False,
)
class PlayResultBest(ModelsV5ViewBase, ReprHelper):
__tablename__ = "play_results_best"
id: Mapped[int]
song_id: Mapped[str]
rating_class: Mapped[int]
score: Mapped[int]
pure: Mapped[Optional[int]]
shiny_pure: Mapped[Optional[int]]
far: Mapped[Optional[int]]
lost: Mapped[Optional[int]]
date: Mapped[Optional[datetime]]
max_recall: Mapped[Optional[int]]
modifier: Mapped[Optional[ArcaeaPlayResultModifier]]
clear_type: Mapped[Optional[ArcaeaPlayResultClearType]]
potential: Mapped[float]
comment: Mapped[Optional[str]]
__table__ = create_view(
name=__tablename__,
selectable=select(
*[
col
for col in inspect(PlayResultCalculated).columns
if col.name != "potential"
],
func.max(PlayResultCalculated.potential).label("potential"),
)
.select_from(PlayResultCalculated)
.group_by(PlayResultCalculated.song_id, PlayResultCalculated.rating_class)
.order_by(PlayResultCalculated.potential.desc()),
metadata=ModelsV5ViewBase.metadata,
cascade_on_drop=False,
)
class CalculatedPotential(ModelsV5ViewBase, ReprHelper):
__tablename__ = "calculated_potential"
b30: Mapped[float]
_select_bests_subquery = (
select(PlayResultBest.potential.label("b30_sum"))
.order_by(PlayResultBest.potential.desc())
.limit(30)
.subquery()
)
__table__ = create_view(
name=__tablename__,
selectable=select(func.avg(_select_bests_subquery.c.b30_sum).label("b30")),
metadata=ModelsV5ViewBase.metadata,
cascade_on_drop=False,
)

View File

@ -1 +1,3 @@
from .api_data import AndrealImageGeneratorApiDataConverter
__all__ = ["AndrealImageGeneratorApiDataConverter"]

View File

@ -1,4 +0,0 @@
from .online import ArcaeaOnlineParser
from .packlist import PacklistParser
from .songlist import SonglistDifficultiesParser, SonglistParser
from .st3 import St3ScoreParser

View File

@ -1,99 +0,0 @@
import contextlib
import json
import math
import time
from os import PathLike
from typing import Any, List, Optional, Union
from sqlalchemy.orm import DeclarativeBase, Session
def fix_timestamp(timestamp: int) -> Union[int, None]:
"""
Some of the `date` column in st3 are strangely truncated. For example,
a `1670283375` may be truncated to `167028`, even `1`. Yes, a single `1`.
To properly handle this situation, we check the timestamp's digits.
If `digits < 5`, we treat this timestamp as a `None`. Otherwise, we try to
fix the timestamp.
:param timestamp: a POSIX timestamp
:return: `None` if the timestamp's digits < 5, otherwise a fixed POSIX timestamp
"""
# find digit length from https://stackoverflow.com/a/2189827/16484891
# CC BY-SA 2.5
# this might give incorrect result when timestamp > 999999999999997,
# see https://stackoverflow.com/a/28883802/16484891 (CC BY-SA 4.0).
# but that's way too later than 9999-12-31 23:59:59, 253402271999,
# I don't think Arcaea would still be an active updated game by then.
# so don't mind those small issues, just use this.
digits = int(math.log10(abs(timestamp))) + 1 if timestamp != 0 else 1
if digits < 5:
return None
timestamp_str = str(timestamp)
current_timestamp_digits = int(math.log10(int(time.time()))) + 1
timestamp_str = timestamp_str.ljust(current_timestamp_digits, "0")
return int(timestamp_str, 10)
def to_db_value(val: Any) -> Any:
if not val:
return None
return json.dumps(val, ensure_ascii=False) if isinstance(val, list) else val
def is_localized(item: dict, key: str, append_localized: bool = True):
item_key = f"{key}_localized" if append_localized else key
subitem: Optional[dict] = item.get(item_key)
return subitem and (
subitem.get("ja")
or subitem.get("ko")
or subitem.get("zh-Hant")
or subitem.get("zh-Hans")
)
def set_model_localized_attrs(
model: DeclarativeBase, item: dict, model_key: str, item_key: Optional[str] = None
):
if item_key is None:
item_key = f"{model_key}_localized"
subitem: dict = item.get(item_key, {})
if not subitem:
return
setattr(model, f"{model_key}_ja", to_db_value(subitem.get("ja")))
setattr(model, f"{model_key}_ko", to_db_value(subitem.get("ko")))
setattr(model, f"{model_key}_zh_hans", to_db_value(subitem.get("zh-Hans")))
setattr(model, f"{model_key}_zh_hant", to_db_value(subitem.get("zh-Hant")))
class ArcaeaParser:
def __init__(self, filepath: Union[str, bytes, PathLike]):
self.filepath = filepath
def read_file_text(self):
file_handle = None
with contextlib.suppress(TypeError):
# original open
file_handle = open(self.filepath, "r", encoding="utf-8")
if file_handle is None:
try:
# or maybe a `pathlib.Path` subset
# or an `importlib.resources.abc.Traversable` like object
# e.g. `zipfile.Path`
file_handle = self.filepath.open(mode="r", encoding="utf-8") # type: ignore
except Exception as e:
raise ValueError("Invalid `filepath`.") from e
with file_handle:
return file_handle.read()
def parse(self) -> List[DeclarativeBase]:
raise NotImplementedError()
def write_database(self, session: Session):
results = self.parse()
for result in results:
session.merge(result)

View File

@ -1,72 +0,0 @@
import json
import logging
from datetime import datetime
from typing import Dict, List, Literal, Optional, TypedDict
from ...models import Score
from .common import ArcaeaParser, fix_timestamp
logger = logging.getLogger(__name__)
class TWebApiRatingMeScoreItem(TypedDict):
song_id: str
difficulty: int
modifier: int
rating: float
score: int
perfect_count: int
near_count: int
miss_count: int
clear_type: int
title: Dict[Literal["ja", "en"], str]
artist: str
time_played: int
bg: str
class TWebApiRatingMeValue(TypedDict):
best_rated_scores: List[TWebApiRatingMeScoreItem]
recent_rated_scores: List[TWebApiRatingMeScoreItem]
class TWebApiRatingMeResult(TypedDict):
success: bool
error_code: Optional[int]
value: Optional[TWebApiRatingMeValue]
class ArcaeaOnlineParser(ArcaeaParser):
def parse(self) -> List[Score]:
api_result_root: TWebApiRatingMeResult = json.loads(self.read_file_text())
api_result_value = api_result_root.get("value")
if not api_result_value:
error_code = api_result_root.get("error_code")
raise ValueError(f"Cannot parse API result, error code {error_code}")
best30_score_items = api_result_value.get("best_rated_scores", [])
recent_score_items = api_result_value.get("recent_rated_scores", [])
score_items = best30_score_items + recent_score_items
date_text = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
results: List[Score] = []
for score_item in score_items:
score = Score()
score.song_id = score_item["song_id"]
score.rating_class = score_item["difficulty"]
score.score = score_item["score"]
score.pure = score_item["perfect_count"]
score.far = score_item["near_count"]
score.lost = score_item["miss_count"]
score.date = fix_timestamp(int(score_item["time_played"] / 1000))
score.modifier = score_item["modifier"]
score.clear_type = score_item["clear_type"]
if score.lost == 0:
score.max_recall = score.pure + score.far
score.comment = f"Parsed from web API at {date_text}"
results.append(score)
return results

View File

@ -1,29 +0,0 @@
import json
from typing import List, Union
from ...models.songs import Pack, PackLocalized
from .common import ArcaeaParser, is_localized, set_model_localized_attrs
class PacklistParser(ArcaeaParser):
def parse(self) -> List[Union[Pack, PackLocalized]]:
packlist_json_root = json.loads(self.read_file_text())
packlist_json = packlist_json_root["packs"]
results: List[Union[Pack, PackLocalized]] = [
Pack(id="single", name="Memory Archive")
]
for item in packlist_json:
pack = Pack()
pack.id = item["id"]
pack.name = item["name_localized"]["en"]
pack.description = item["description_localized"]["en"] or None
results.append(pack)
if is_localized(item, "name") or is_localized(item, "description"):
pack_localized = PackLocalized(id=pack.id)
set_model_localized_attrs(pack_localized, item, "name")
set_model_localized_attrs(pack_localized, item, "description")
results.append(pack_localized)
return results

View File

@ -1,101 +0,0 @@
import json
from typing import List, Union
from ...models.songs import Difficulty, DifficultyLocalized, Song, SongLocalized
from .common import ArcaeaParser, is_localized, set_model_localized_attrs, to_db_value
class SonglistParser(ArcaeaParser):
def parse(
self,
) -> List[Union[Song, SongLocalized, Difficulty, DifficultyLocalized]]:
songlist_json_root = json.loads(self.read_file_text())
songlist_json = songlist_json_root["songs"]
results = []
for item in songlist_json:
song = Song()
song.idx = item["idx"]
song.id = item["id"]
song.title = item["title_localized"]["en"]
song.artist = item["artist"]
song.bpm = item["bpm"]
song.bpm_base = item["bpm_base"]
song.set = item["set"]
song.audio_preview = item["audioPreview"]
song.audio_preview_end = item["audioPreviewEnd"]
song.side = item["side"]
song.version = item["version"]
song.date = item["date"]
song.bg = to_db_value(item.get("bg"))
song.bg_inverse = to_db_value(item.get("bg_inverse"))
if item.get("bg_daynight"):
song.bg_day = to_db_value(item["bg_daynight"].get("day"))
song.bg_night = to_db_value(item["bg_daynight"].get("night"))
if item.get("source_localized"):
song.source = item["source_localized"]["en"]
song.source_copyright = to_db_value(item.get("source_copyright"))
results.append(song)
if (
is_localized(item, "title")
or is_localized(item, "search_title", append_localized=False)
or is_localized(item, "search_artist", append_localized=False)
or is_localized(item, "source")
):
song_localized = SongLocalized(id=song.id)
set_model_localized_attrs(song_localized, item, "title")
set_model_localized_attrs(
song_localized, item, "search_title", "search_title"
)
set_model_localized_attrs(
song_localized, item, "search_artist", "search_artist"
)
set_model_localized_attrs(song_localized, item, "source")
results.append(song_localized)
return results
class SonglistDifficultiesParser(ArcaeaParser):
def parse(self) -> List[Union[Difficulty, DifficultyLocalized]]:
songlist_json_root = json.loads(self.read_file_text())
songlist_json = songlist_json_root["songs"]
results = []
for song_item in songlist_json:
if not song_item.get("difficulties"):
continue
for item in song_item["difficulties"]:
if item["rating"] == 0:
continue
chart = Difficulty(song_id=song_item["id"])
chart.rating_class = item["ratingClass"]
chart.rating = item["rating"]
chart.rating_plus = item.get("ratingPlus") or False
chart.chart_designer = item["chartDesigner"]
chart.jacket_desginer = item.get("jacketDesigner") or None
chart.audio_override = item.get("audioOverride") or False
chart.jacket_override = item.get("jacketOverride") or False
chart.jacket_night = item.get("jacketNight") or None
chart.title = item.get("title_localized", {}).get("en") or None
chart.artist = item.get("artist") or None
chart.bg = item.get("bg") or None
chart.bg_inverse = item.get("bg_inverse")
chart.bpm = item.get("bpm") or None
chart.bpm_base = item.get("bpm_base") or None
chart.version = item.get("version") or None
chart.date = item.get("date") or None
results.append(chart)
if is_localized(item, "title") or is_localized(item, "artist"):
chart_localized = DifficultyLocalized(
song_id=chart.song_id, rating_class=chart.rating_class
)
set_model_localized_attrs(chart_localized, item, "title")
set_model_localized_attrs(chart_localized, item, "artist")
results.append(chart_localized)
return results

View File

@ -1,73 +0,0 @@
import logging
import sqlite3
from typing import List
from sqlalchemy import select
from sqlalchemy.orm import Session
from ...models.scores import Score
from .common import ArcaeaParser, fix_timestamp
logger = logging.getLogger(__name__)
class St3ScoreParser(ArcaeaParser):
def parse(self) -> List[Score]:
items = []
with sqlite3.connect(self.filepath) as st3_conn:
cursor = st3_conn.cursor()
db_scores = cursor.execute(
"SELECT songId, songDifficulty, score, perfectCount, nearCount, missCount, "
"date, modifier FROM scores"
).fetchall()
for (
song_id,
rating_class,
score,
pure,
far,
lost,
date,
modifier,
) in db_scores:
clear_type = cursor.execute(
"SELECT clearType FROM cleartypes WHERE songId = ? AND songDifficulty = ?",
(song_id, rating_class),
).fetchone()[0]
items.append(
Score(
song_id=song_id,
rating_class=rating_class,
score=score,
pure=pure,
far=far,
lost=lost,
date=fix_timestamp(date),
modifier=modifier,
clear_type=clear_type,
comment="Parsed from st3",
)
)
return items
def write_database(self, session: Session, *, skip_duplicate=True):
parsed_scores = self.parse()
for parsed_score in parsed_scores:
query_score = session.scalar(
select(Score).where(
(Score.song_id == parsed_score.song_id)
& (Score.rating_class == parsed_score.rating_class)
& (Score.score == parsed_score.score)
)
)
if query_score and skip_duplicate:
logger.info(
"%r skipped because potential duplicate item %r found.",
parsed_score,
query_score,
)
continue
session.add(parsed_score)

View File

@ -1 +1,3 @@
from .arcsong_db import ArcsongDbParser
__all__ = ["ArcsongDbParser"]

View File

@ -3,7 +3,7 @@ from typing import List
from sqlalchemy.orm import Session
from ...models.songs import ChartInfo
from arcaea_offline.database.models.v4 import ChartInfo
class ArcsongDbParser:

View File

@ -5,7 +5,7 @@ from typing import List, Optional, TypedDict
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ...models import (
from arcaea_offline.database.models.v4 import (
ChartInfo,
Difficulty,
DifficultyLocalized,

View File

@ -1 +0,0 @@
from .parser import ChartInfoDbParser

View File

@ -1,35 +0,0 @@
import contextlib
import sqlite3
from typing import List
from sqlalchemy.orm import Session
from ...models.songs import ChartInfo
class ChartInfoDbParser:
def __init__(self, filepath):
self.filepath = filepath
def parse(self) -> List[ChartInfo]:
results = []
with sqlite3.connect(self.filepath) as conn:
with contextlib.closing(conn.cursor()) as cursor:
db_results = cursor.execute(
"SELECT song_id, rating_class, constant, notes FROM charts_info"
).fetchall()
for result in db_results:
chart = ChartInfo(
song_id=result[0],
rating_class=result[1],
constant=result[2],
notes=result[3] or None,
)
results.append(chart)
return results
def write_database(self, session: Session):
results = self.parse()
for result in results:
session.merge(result)

View File

@ -1,2 +0,0 @@
from . import exporters
from .types import ArcaeaOfflineDEFV2_Score, ScoreExport

View File

@ -1,4 +1,5 @@
from ...models import Score
from arcaea_offline.database.models.v4 import Score
from .types import ArcaeaOfflineDEFV2_ScoreItem, ScoreExport

View File

@ -0,0 +1,10 @@
from .lists import ArcaeaPacklistParser, ArcaeaSonglistParser
from .online import ArcaeaOnlineApiParser
from .st3 import ArcaeaSt3Parser
__all__ = [
"ArcaeaPacklistParser",
"ArcaeaSonglistParser",
"ArcaeaOnlineApiParser",
"ArcaeaSt3Parser",
]

View File

@ -0,0 +1,31 @@
from typing import Union
def fix_timestamp(timestamp: int) -> Union[int, None]:
"""
Some of the `date` column in st3 are unexpectedly truncated. For example,
a `1670283375` may be truncated to `167028`, even a single `1`.
To properly handle this:
If `timestamp > 1489017600` (the release date of Arcaea), consider it's ok.
Otherwise, if the timestamp is 'fixable'
(`1489 <= timestamp <= 9999` or `timestamp > 14889`),
pad zeros to the end of timestamp.
For example, a `1566` will be padded to `1566000000`.
Otherwise, treat the timestamp as `None`.
:param timestamp: `date` value
"""
if timestamp > 1489017600: # noqa: PLR2004
return timestamp
timestamp_fixable = 1489 <= timestamp <= 9999 or timestamp > 14889 # noqa: PLR2004
if not timestamp_fixable:
return None
timestamp_str = str(timestamp)
timestamp_str = timestamp_str.ljust(10, "0")
return int(timestamp_str, 10)

View File

@ -0,0 +1,178 @@
"""
packlist and songlist parsers
"""
import json
from typing import List, Union
from arcaea_offline.constants.enums import (
ArcaeaLanguage,
ArcaeaRatingClass,
ArcaeaSongSide,
)
from arcaea_offline.database.models.v5 import (
Difficulty,
DifficultyLocalized,
Pack,
PackLocalized,
Song,
SongLocalized,
SongSearchWord,
)
class ArcaeaListParser:
def __init__(self, list_text: str):
self.list_text = list_text
class ArcaeaPacklistParser(ArcaeaListParser):
def parse(self) -> List[Union[Pack, PackLocalized]]:
root = json.loads(self.list_text)
packs = root["packs"]
results: List[Union[Pack, PackLocalized]] = [
Pack(id="single", name="Memory Archive")
]
for item in packs:
pack = Pack()
pack.id = item["id"]
pack.name = item["name_localized"]["en"]
pack.description = item["description_localized"]["en"] or None
results.append(pack)
for key in ArcaeaLanguage:
name_localized = item["name_localized"].get(key.value, None)
description_localized = item["description_localized"].get(
key.value, None
)
if name_localized or description_localized:
pack_localized = PackLocalized(id=pack.id)
pack_localized.lang = key.value
pack_localized.name = name_localized
pack_localized.description = description_localized
results.append(pack_localized)
return results
class ArcaeaSonglistParser(ArcaeaListParser):
def parse_songs(self) -> List[Union[Song, SongLocalized, SongSearchWord]]:
root = json.loads(self.list_text)
songs = root["songs"]
results = []
for item in songs:
song = Song()
song.idx = item["idx"]
song.id = item["id"]
song.title = item["title_localized"]["en"]
song.artist = item["artist"]
song.bpm = item["bpm"]
song.bpm_base = item["bpm_base"]
song.pack_id = item["set"]
song.audio_preview = item["audioPreview"]
song.audio_preview_end = item["audioPreviewEnd"]
song.side = ArcaeaSongSide(item["side"])
song.version = item["version"]
song.date = item["date"]
song.bg = item.get("bg")
song.bg_inverse = item.get("bg_inverse")
if item.get("bg_daynight"):
song.bg_day = item["bg_daynight"].get("day")
song.bg_night = item["bg_daynight"].get("night")
if item.get("source_localized"):
song.source = item["source_localized"]["en"]
song.source_copyright = item.get("source_copyright")
results.append(song)
for lang in ArcaeaLanguage:
# SongLocalized objects
title_localized = item["title_localized"].get(lang.value, None)
source_localized = item.get("source_localized", {}).get(
lang.value, None
)
if title_localized or source_localized:
song_localized = SongLocalized(id=song.id)
song_localized.lang = lang.value
song_localized.title = title_localized
song_localized.source = source_localized
results.append(song_localized)
# SongSearchTitle
search_titles = item.get("search_title", {}).get(lang.value, None)
if search_titles:
for search_title in search_titles:
song_search_word = SongSearchWord(
id=song.id, lang=lang.value, type=1, value=search_title
)
results.append(song_search_word)
search_artists = item.get("search_artist", {}).get(lang.value, None)
if search_artists:
for search_artist in search_artists:
song_search_word = SongSearchWord(
id=song.id, lang=lang.value, type=2, value=search_artist
)
results.append(song_search_word)
return results
def parse_difficulties(self) -> List[Union[Difficulty, DifficultyLocalized]]:
root = json.loads(self.list_text)
songs = root["songs"]
results = []
for song in songs:
difficulties = song.get("difficulties")
if not difficulties:
continue
for item in difficulties:
if item["rating"] == 0:
continue
difficulty = Difficulty()
difficulty.song_id = song["id"]
difficulty.rating_class = ArcaeaRatingClass(item["ratingClass"])
difficulty.rating = item["rating"]
difficulty.rating_plus = item.get("ratingPlus") or False
difficulty.chart_designer = item["chartDesigner"]
difficulty.jacket_desginer = item.get("jacketDesigner") or None
difficulty.audio_override = item.get("audioOverride") or False
difficulty.jacket_override = item.get("jacketOverride") or False
difficulty.jacket_night = item.get("jacketNight") or None
difficulty.title = item.get("title_localized", {}).get("en") or None
difficulty.artist = item.get("artist") or None
difficulty.bg = item.get("bg") or None
difficulty.bg_inverse = item.get("bg_inverse")
difficulty.bpm = item.get("bpm") or None
difficulty.bpm_base = item.get("bpm_base") or None
difficulty.version = item.get("version") or None
difficulty.date = item.get("date") or None
results.append(difficulty)
for lang in ArcaeaLanguage:
title_localized = item.get("title_localized", {}).get(
lang.value, None
)
artist_localized = item.get("artist_localized", {}).get(
lang.value, None
)
if title_localized or artist_localized:
difficulty_localized = DifficultyLocalized(
song_id=difficulty.song_id,
rating_class=difficulty.rating_class,
)
difficulty_localized.lang = lang.value
difficulty_localized.title = title_localized
difficulty_localized.artist = artist_localized
results.append(difficulty_localized)
return results
def parse_all(self):
return self.parse_songs() + self.parse_difficulties()

View File

@ -0,0 +1,97 @@
import json
import logging
from datetime import datetime, timezone
from typing import Dict, List, Literal, Optional, TypedDict
from arcaea_offline.constants.enums import (
ArcaeaPlayResultClearType,
ArcaeaPlayResultModifier,
ArcaeaRatingClass,
)
from arcaea_offline.database.models.v5 import PlayResult
from .common import fix_timestamp
logger = logging.getLogger(__name__)
class _RatingMePlayResultItem(TypedDict):
song_id: str
difficulty: int
modifier: int
rating: float
score: int
perfect_count: int
near_count: int
miss_count: int
clear_type: int
title: Dict[Literal["ja", "en"], str]
artist: str
time_played: int
bg: str
class _RatingMeValue(TypedDict):
best_rated_scores: List[_RatingMePlayResultItem]
recent_rated_scores: List[_RatingMePlayResultItem]
class _RatingMeResponse(TypedDict):
success: bool
error_code: Optional[int]
value: Optional[_RatingMeValue]
class ArcaeaOnlineApiParser:
def __init__(self, api_result_text: str):
self.api_result_text = api_result_text
self.api_result: _RatingMeResponse = json.loads(api_result_text)
def parse(self) -> List[PlayResult]:
api_result_value = self.api_result.get("value")
if not api_result_value:
error_code = self.api_result.get("error_code")
raise ValueError(
f"Cannot parse Arcaea Online API result, error code {error_code}"
)
best30_items = api_result_value.get("best_rated_scores", [])
recent_items = api_result_value.get("recent_rated_scores", [])
items = best30_items + recent_items
date_text = (
datetime.now(tz=timezone.utc).astimezone().isoformat(timespec="seconds")
)
results: List[PlayResult] = []
results_time_played = []
for item in items:
date_millis = fix_timestamp(item["time_played"])
if date_millis in results_time_played:
# filter out duplicate play results
continue
if date_millis:
date = datetime.fromtimestamp(date_millis / 1000).astimezone()
results_time_played.append(date_millis)
else:
date = None
play_result = PlayResult()
play_result.song_id = item["song_id"]
play_result.rating_class = ArcaeaRatingClass(item["difficulty"])
play_result.score = item["score"]
play_result.pure = item["perfect_count"]
play_result.far = item["near_count"]
play_result.lost = item["miss_count"]
play_result.date = date
play_result.modifier = ArcaeaPlayResultModifier(item["modifier"])
play_result.clear_type = ArcaeaPlayResultClearType(item["clear_type"])
if play_result.lost == 0:
play_result.max_recall = play_result.pure + play_result.far
play_result.comment = f"Parsed from web API at {date_text}"
results.append(play_result)
return results

View File

@ -0,0 +1,118 @@
"""
Game database play results importer
"""
import logging
import sqlite3
from datetime import datetime, timezone
from typing import List, overload
from arcaea_offline.constants.enums import (
ArcaeaPlayResultClearType,
ArcaeaPlayResultModifier,
ArcaeaRatingClass,
)
from arcaea_offline.database.models.v5 import PlayResult
from .common import fix_timestamp
logger = logging.getLogger(__name__)
class ArcaeaSt3Parser:
@classmethod
@overload
def parse(cls, db: sqlite3.Connection) -> List[PlayResult]: ...
@classmethod
@overload
def parse(cls, db: sqlite3.Cursor) -> List[PlayResult]: ...
@classmethod
def parse(cls, db) -> List[PlayResult]:
if isinstance(db, sqlite3.Connection):
return cls.parse(db.cursor())
if not isinstance(db, sqlite3.Cursor):
raise TypeError(
"Unknown overload of `db`. Expected `sqlite3.Connection` or `sqlite3.Cursor`."
)
entities = []
query_results = db.execute("""
SELECT s.id AS _id, s.songId, s.songDifficulty AS ratingClass, s.score,
s.perfectCount AS pure, s.nearCount AS far, s.missCount AS lost,
s.`date`, s.modifier, ct.clearType
FROM scores s JOIN cleartypes ct
ON s.songId = ct.songId AND s.songDifficulty = ct.songDifficulty""")
# maybe `s.id = ct.id`?
now = datetime.now(tz=timezone.utc)
import_comment = (
f"Imported from st3 at {now.astimezone().isoformat(timespec='seconds')}"
)
for result in query_results:
(
_id,
song_id,
rating_class,
score,
pure,
far,
lost,
date,
modifier,
clear_type,
) = result
try:
rating_class_enum = ArcaeaRatingClass(rating_class)
except ValueError:
logger.warning(
"Unknown rating class [%r] at entry id %d, skipping!",
rating_class,
_id,
)
continue
try:
clear_type_enum = ArcaeaPlayResultClearType(clear_type)
except ValueError:
logger.warning(
"Unknown clear type [%r] at entry id %d, falling back to `None`!",
clear_type,
_id,
)
clear_type_enum = None
try:
modifier_enum = ArcaeaPlayResultModifier(modifier)
except ValueError:
logger.warning(
"Unknown modifier [%r] at entry id %d, falling back to `None`!",
modifier,
_id,
)
modifier_enum = None
if date := fix_timestamp(date):
date = datetime.fromtimestamp(date).astimezone()
else:
date = None
entities.append(
PlayResult(
song_id=song_id,
rating_class=rating_class_enum,
score=score,
pure=pure,
far=far,
lost=lost,
date=date,
modifier=modifier_enum,
clear_type=clear_type_enum,
comment=import_comment,
)
)
return entities

View File

@ -0,0 +1,42 @@
import sqlite3
from contextlib import closing
from typing import List, overload
from arcaea_offline.constants.enums.arcaea import ArcaeaRatingClass
from arcaea_offline.database.models.v5 import ChartInfo
class ChartInfoDatabaseParser:
@classmethod
@overload
def parse(cls, conn: sqlite3.Connection) -> List[ChartInfo]: ...
@classmethod
@overload
def parse(cls, conn: sqlite3.Cursor) -> List[ChartInfo]: ...
@classmethod
def parse(cls, conn) -> List[ChartInfo]:
if isinstance(conn, sqlite3.Connection):
with closing(conn.cursor()) as cur:
return cls.parse(cur)
if not isinstance(conn, sqlite3.Cursor):
raise ValueError("conn must be sqlite3.Connection or sqlite3.Cursor!")
db_items = conn.execute(
"SELECT song_id, rating_class, constant, notes FROM charts_info"
).fetchall()
results: List[ChartInfo] = []
for item in db_items:
(song_id, rating_class, constant, notes) = item
chart_info = ChartInfo()
chart_info.song_id = song_id
chart_info.rating_class = ArcaeaRatingClass(rating_class)
chart_info.constant = constant
chart_info.notes = notes
results.append(chart_info)
return results

View File

@ -1 +1,3 @@
from .b30_csv import SmartRteB30CsvConverter
__all__ = ["SmartRteB30CsvConverter"]

View File

@ -1,2 +1,7 @@
from .play_result import PlayResultFormatter
from .rating_class import RatingClassFormatter
__all__ = [
"PlayResultFormatter",
"RatingClassFormatter",
]

View File

@ -1,27 +1,53 @@
import pytest
from sqlalchemy import create_engine
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
# region sqlalchemy fixtures
# from https://medium.com/@vittorio.camisa/agile-database-integration-tests-with-python-sqlalchemy-and-factory-boy-6824e8fe33a1
engine = create_engine("sqlite:///:memory:")
Session = sessionmaker()
@pytest.fixture(scope="module")
@pytest.fixture(scope="session")
def db_conn():
connection = engine.connect()
yield connection
connection.close()
conn = engine.connect()
yield conn
conn.close()
@pytest.fixture(scope="function")
@pytest.fixture()
def db_session(db_conn):
transaction = db_conn.begin()
session = Session(bind=db_conn)
yield session
session.close()
transaction.rollback()
# drop everything
query_tables = db_conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table'")
).fetchall()
for row in query_tables:
table_name = row[0]
db_conn.execute(text(f"DROP TABLE {table_name}"))
query_views = db_conn.execute(
text("SELECT name FROM sqlite_master WHERE type='view'")
).fetchall()
for row in query_views:
view_name = row[0]
db_conn.execute(text(f"DROP VIEW {view_name}"))
query_indexes = db_conn.execute(
text("SELECT name FROM sqlite_master WHERE type='index'")
).fetchall()
for row in query_indexes:
index_name = row[0]
db_conn.execute(text(f"DROP INDEX {index_name}"))
query_triggers = db_conn.execute(
text("SELECT name FROM sqlite_master WHERE type='trigger'")
).fetchall()
for row in query_triggers:
trigger_name = row[0]
db_conn.execute(text(f"DROP TRIGGER {trigger_name}"))
# endregion

View File

@ -1,5 +0,0 @@
from sqlalchemy import Engine, create_engine, inspect
def create_engine_in_memory():
return create_engine("sqlite:///:memory:")

View File

@ -41,7 +41,7 @@ class TestCustomTypes:
)
).one()[0]
TestBase.metadata.create_all(db_session.bind)
TestBase.metadata.create_all(db_session.bind, checkfirst=False)
basic_obj = IntEnumTestModel(id=1, value=TestIntEnum.TWO)
null_obj = IntEnumTestModel(id=2, value=None)
@ -53,7 +53,7 @@ class TestCustomTypes:
assert _query_value(2) is None
def test_tz_datetime(self, db_session):
TestBase.metadata.create_all(db_session.bind)
TestBase.metadata.create_all(db_session.bind, checkfirst=False)
dt1 = datetime.now(tz=timezone(timedelta(hours=8)))

View File

@ -1,118 +0,0 @@
from sqlalchemy import Engine
from sqlalchemy.orm import Session
from arcaea_offline.models.songs import (
Chart,
ChartInfo,
Difficulty,
Pack,
Song,
SongsBase,
SongsViewBase,
)
from ..db import create_engine_in_memory
def _song(**kw):
defaults = {"artist": "test"}
defaults.update(kw)
return Song(**defaults)
def _difficulty(**kw):
defaults = {"rating_plus": False, "audio_override": False, "jacket_override": False}
defaults.update(kw)
return Difficulty(**defaults)
class Test_Chart:
def init_db(self, engine: Engine):
SongsBase.metadata.create_all(engine)
SongsViewBase.metadata.create_all(engine)
def db(self):
db = create_engine_in_memory()
self.init_db(db)
return db
def test_chart_info(self):
pre_entites = [
Pack(id="test", name="Test Pack"),
_song(idx=0, id="song0", set="test", title="Full Chart Info"),
_song(idx=1, id="song1", set="test", title="Partial Chart Info"),
_song(idx=2, id="song2", set="test", title="No Chart Info"),
_difficulty(song_id="song0", rating_class=2, rating=9),
_difficulty(song_id="song1", rating_class=2, rating=9),
_difficulty(song_id="song2", rating_class=2, rating=9),
ChartInfo(song_id="song0", rating_class=2, constant=90, notes=1234),
ChartInfo(song_id="song1", rating_class=2, constant=90),
]
db = self.db()
with Session(db) as session:
session.add_all(pre_entites)
session.commit()
chart_song0_ratingclass2 = (
session.query(Chart)
.where((Chart.song_id == "song0") & (Chart.rating_class == 2))
.one()
)
assert chart_song0_ratingclass2.constant == 90
assert chart_song0_ratingclass2.notes == 1234
chart_song1_ratingclass2 = (
session.query(Chart)
.where((Chart.song_id == "song1") & (Chart.rating_class == 2))
.one()
)
assert chart_song1_ratingclass2.constant == 90
assert chart_song1_ratingclass2.notes is None
chart_song2_ratingclass2 = (
session.query(Chart)
.where((Chart.song_id == "song2") & (Chart.rating_class == 2))
.first()
)
assert chart_song2_ratingclass2 is None
def test_difficulty_title_override(self):
pre_entites = [
Pack(id="test", name="Test Pack"),
_song(idx=0, id="test", set="test", title="Test"),
_difficulty(song_id="test", rating_class=0, rating=2),
_difficulty(song_id="test", rating_class=1, rating=5),
_difficulty(song_id="test", rating_class=2, rating=8),
_difficulty(
song_id="test", rating_class=3, rating=10, title="TEST ~REVIVE~"
),
ChartInfo(song_id="test", rating_class=0, constant=10),
ChartInfo(song_id="test", rating_class=1, constant=10),
ChartInfo(song_id="test", rating_class=2, constant=10),
ChartInfo(song_id="test", rating_class=3, constant=10),
]
db = self.db()
with Session(db) as session:
session.add_all(pre_entites)
session.commit()
charts_original_title = (
session.query(Chart)
.where((Chart.song_id == "test") & (Chart.rating_class in [0, 1, 2]))
.all()
)
assert all(chart.title == "Test" for chart in charts_original_title)
chart_overrided_title = (
session.query(Chart)
.where((Chart.song_id == "test") & (Chart.rating_class == 3))
.one()
)
assert chart_overrided_title.title == "TEST ~REVIVE~"

View File

View File

@ -0,0 +1,108 @@
from arcaea_offline.database.models.v4.songs import (
Chart,
ChartInfo,
Difficulty,
Pack,
Song,
SongsBase,
SongsViewBase,
)
def _song(**kw):
defaults = {"artist": "test"}
defaults.update(kw)
return Song(**defaults)
def _difficulty(**kw):
defaults = {"rating_plus": False, "audio_override": False, "jacket_override": False}
defaults.update(kw)
return Difficulty(**defaults)
class Test_Chart:
def init_db(self, session):
SongsBase.metadata.create_all(session.bind, checkfirst=False)
SongsViewBase.metadata.create_all(session.bind, checkfirst=False)
def test_chart_info(self, db_session):
self.init_db(db_session)
pre_entites = [
Pack(id="test", name="Test Pack"),
_song(idx=0, id="song0", set="test", title="Full Chart Info"),
_song(idx=1, id="song1", set="test", title="Partial Chart Info"),
_song(idx=2, id="song2", set="test", title="No Chart Info"),
_difficulty(song_id="song0", rating_class=2, rating=9),
_difficulty(song_id="song1", rating_class=2, rating=9),
_difficulty(song_id="song2", rating_class=2, rating=9),
ChartInfo(song_id="song0", rating_class=2, constant=90, notes=1234),
ChartInfo(song_id="song1", rating_class=2, constant=90),
]
db_session.add_all(pre_entites)
db_session.commit()
chart_song0_ratingclass2 = (
db_session.query(Chart)
.where((Chart.song_id == "song0") & (Chart.rating_class == 2))
.one()
)
assert chart_song0_ratingclass2.constant == 90
assert chart_song0_ratingclass2.notes == 1234
chart_song1_ratingclass2 = (
db_session.query(Chart)
.where((Chart.song_id == "song1") & (Chart.rating_class == 2))
.one()
)
assert chart_song1_ratingclass2.constant == 90
assert chart_song1_ratingclass2.notes is None
chart_song2_ratingclass2 = (
db_session.query(Chart)
.where((Chart.song_id == "song2") & (Chart.rating_class == 2))
.first()
)
assert chart_song2_ratingclass2 is None
def test_difficulty_title_override(self, db_session):
self.init_db(db_session)
pre_entites = [
Pack(id="test", name="Test Pack"),
_song(idx=0, id="test", set="test", title="Test"),
_difficulty(song_id="test", rating_class=0, rating=2),
_difficulty(song_id="test", rating_class=1, rating=5),
_difficulty(song_id="test", rating_class=2, rating=8),
_difficulty(
song_id="test", rating_class=3, rating=10, title="TEST ~REVIVE~"
),
ChartInfo(song_id="test", rating_class=0, constant=10),
ChartInfo(song_id="test", rating_class=1, constant=10),
ChartInfo(song_id="test", rating_class=2, constant=10),
ChartInfo(song_id="test", rating_class=3, constant=10),
]
db_session.add_all(pre_entites)
db_session.commit()
charts_original_title = (
db_session.query(Chart)
.where((Chart.song_id == "test") & (Chart.rating_class in [0, 1, 2]))
.all()
)
assert all(chart.title == "Test" for chart in charts_original_title)
chart_overrided_title = (
db_session.query(Chart)
.where((Chart.song_id == "test") & (Chart.rating_class == 3))
.one()
)
assert chart_overrided_title.title == "TEST ~REVIVE~"

View File

View File

@ -0,0 +1,143 @@
"""
Database model v5 common relationships
┌──────┐ ┌──────┐ ┌────────────┐ ┌────────────┐
│ Pack ◄───► Song ◄───► Difficulty ◄───┤ PlayResult │
└──────┘ └──┬───┘ └─────▲──────┘ └────────────┘
│ │
│ ┌─────▼─────┐
└───────► ChartInfo │
└───────────┘
"""
from arcaea_offline.constants.enums import ArcaeaRatingClass
from arcaea_offline.database.models.v5 import (
ChartInfo,
Difficulty,
ModelsV5Base,
Pack,
PlayResult,
Song,
)
class TestSongRelationships:
@staticmethod
def init_db(session):
ModelsV5Base.metadata.create_all(session.bind)
def test_relationships(self, db_session):
self.init_db(db_session)
song_id = "test_song"
title_en = "Test Lorem Ipsum"
artist_en = "Test Artist"
pack = Pack(
id="test_pack",
name="Test Pack",
description="This is a test pack.",
)
song = Song(
idx=1,
id=song_id,
title=title_en,
artist=artist_en,
pack_id=pack.id,
)
difficulty_pst = Difficulty(
song_id=song.id,
rating_class=ArcaeaRatingClass.PAST,
rating=2,
rating_plus=False,
)
chart_info_pst = ChartInfo(
song_id=song.id,
rating_class=ArcaeaRatingClass.PAST,
constant=20,
notes=200,
)
difficulty_prs = Difficulty(
song_id=song.id,
rating_class=ArcaeaRatingClass.PRESENT,
rating=7,
rating_plus=True,
)
chart_info_prs = ChartInfo(
song_id=song.id,
rating_class=ArcaeaRatingClass.PRESENT,
constant=78,
notes=780,
)
difficulty_ftr = Difficulty(
song_id=song.id,
rating_class=ArcaeaRatingClass.FUTURE,
rating=10,
rating_plus=True,
)
chart_info_ftr = ChartInfo(
song_id=song.id,
rating_class=ArcaeaRatingClass.FUTURE,
constant=109,
notes=1090,
)
difficulty_etr = Difficulty(
song_id=song.id,
rating_class=ArcaeaRatingClass.ETERNAL,
rating=9,
rating_plus=True,
)
play_result_ftr = PlayResult(
song_id=song.id,
rating_class=ArcaeaRatingClass.FUTURE,
score=123456,
)
db_session.add_all(
[
pack,
song,
difficulty_pst,
chart_info_pst,
difficulty_prs,
chart_info_prs,
difficulty_ftr,
chart_info_ftr,
difficulty_etr,
play_result_ftr,
]
)
db_session.commit()
assert pack.songs == [song]
assert song.pack == pack
assert song.difficulties == [
difficulty_pst,
difficulty_prs,
difficulty_ftr,
difficulty_etr,
]
assert song.charts_info == [chart_info_pst, chart_info_prs, chart_info_ftr]
assert difficulty_pst.song == song
assert difficulty_prs.song == song
assert difficulty_ftr.song == song
assert difficulty_etr.song == song
assert difficulty_pst.chart_info == chart_info_pst
assert difficulty_prs.chart_info == chart_info_prs
assert difficulty_ftr.chart_info == chart_info_ftr
assert difficulty_etr.chart_info is None
assert chart_info_pst.difficulty == difficulty_pst
assert chart_info_prs.difficulty == difficulty_prs
assert chart_info_ftr.difficulty == difficulty_ftr
assert play_result_ftr.difficulty == difficulty_ftr

View File

@ -0,0 +1,69 @@
"""
Database model v5 relationships
Pack <> PackLocalized
"""
from arcaea_offline.constants.enums import ArcaeaLanguage
from arcaea_offline.database.models.v5 import ModelsV5Base, Pack, PackLocalized
class TestPackRelationships:
@staticmethod
def init_db(session):
ModelsV5Base.metadata.create_all(session.bind)
def test_localized_objects(self, db_session):
self.init_db(db_session)
pack_id = "test_pack"
name_en = "Test Pack"
description_en = "Travel through common database models\nfrom the unpopular framework 'Arcaea Offline'\ntogether with an ordinary partner ''."
pack = Pack(
id=pack_id,
name=name_en,
description=description_en,
)
pkid_ja = 1
description_ja = "普通のパートナー「∅」と一緒に、\n不人気フレームワーク「Arcaea Offline」より、\n一般的なデータベース・モデルを通過する。"
pack_localized_ja = PackLocalized(
pkid=pkid_ja,
id=pack_id,
lang=ArcaeaLanguage.JA.value,
name=None,
description=description_ja,
)
pkid_zh_hans = 2
description_zh_hans = "与平凡的「∅」一起,\n在没人用的「Arcaea Offline」框架里\n一同探索随处可见的数据库模型。"
pack_localized_zh_hans = PackLocalized(
pkid=pkid_zh_hans,
id=pack_id,
lang=ArcaeaLanguage.ZH_HANS.value,
name=None,
description=description_zh_hans,
)
db_session.add_all([pack, pack_localized_ja])
db_session.commit()
assert len(pack.localized_objects) == len([pack_localized_ja])
assert pack_localized_ja.parent.description == pack.description
# relationships should be viewonly
new_pack = Pack(
id=f"{pack_id}_new",
name="NEW",
description="new new pack",
)
db_session.add(new_pack)
pack_localized_ja.parent = new_pack
pack.localized_objects.append(pack_localized_zh_hans)
db_session.commit()
assert pack_localized_ja.parent == pack
assert len(pack.localized_objects) == 1

View File

@ -0,0 +1,110 @@
"""
Database models v5
Chart functionalities
- basic data handling
- Difficulty song info overriding
"""
from arcaea_offline.constants.enums.arcaea import ArcaeaRatingClass
from arcaea_offline.database.models.v5 import (
Chart,
ChartInfo,
Difficulty,
ModelsV5Base,
ModelsV5ViewBase,
Pack,
Song,
)
class TestChart:
def init_db(self, session):
ModelsV5Base.metadata.create_all(session.bind)
ModelsV5ViewBase.metadata.create_all(session.bind)
def test_basic(self, db_session):
self.init_db(db_session)
pack_id = "test_pack"
song_id = "test_song"
rating_class = ArcaeaRatingClass.FUTURE
pack = Pack(id=pack_id, name="Test Pack")
song = Song(
idx=2,
id=song_id,
title="~TEST~",
artist="~test~",
pack_id=pack_id,
)
difficulty = Difficulty(
song_id=song_id,
rating_class=rating_class,
rating=9,
rating_plus=True,
)
chart_info = ChartInfo(
song_id=song_id,
rating_class=rating_class,
constant=98,
notes=980,
)
db_session.add_all([pack, song, difficulty, chart_info])
chart: Chart = (
db_session.query(Chart)
.where((Chart.song_id == song_id) & (Chart.rating_class == rating_class))
.one()
)
# `song_id` and `rating_class` are guarded by the WHERE clause above
assert chart.song_idx == song.idx
assert chart.title == song.title
assert chart.artist == song.artist
assert chart.pack_id == song.pack_id
assert chart.rating == difficulty.rating
assert chart.rating_plus == difficulty.rating_plus
assert chart.constant == chart_info.constant
assert chart.notes == chart_info.notes
def test_difficulty_override(self, db_session):
self.init_db(db_session)
pack_id = "test_pack"
song_id = "test_song"
rating_class = ArcaeaRatingClass.FUTURE
pack = Pack(id=pack_id, name="Test Pack")
song = Song(
idx=2,
id=song_id,
title="~TEST~",
artist="~test~",
pack_id=pack_id,
)
difficulty = Difficulty(
song_id=song_id,
rating_class=rating_class,
rating=9,
rating_plus=True,
title="~TEST DIFF~",
artist="~diff~",
)
chart_info = ChartInfo(
song_id=song_id,
rating_class=rating_class,
constant=98,
notes=980,
)
db_session.add_all([pack, song, difficulty, chart_info])
chart: Chart = (
db_session.query(Chart)
.where((Chart.song_id == song_id) & (Chart.rating_class == rating_class))
.one()
)
assert chart.song_idx == song.idx
assert chart.title == difficulty.title
assert chart.artist == difficulty.artist

View File

@ -0,0 +1,88 @@
import json
from datetime import datetime, timezone
from arcaea_offline.constants.enums.arcaea import (
ArcaeaPlayResultClearType,
ArcaeaPlayResultModifier,
ArcaeaRatingClass,
)
from arcaea_offline.database.models.v5.play_results import PlayResult
from arcaea_offline.external.importers.arcaea.online import ArcaeaOnlineApiParser
API_RESULT = {
"success": True,
"value": {
"best_rated_scores": [
{
"song_id": "test1",
"difficulty": 2,
"modifier": 0,
"rating": 12.5,
"score": 9908123,
"perfect_count": 1234,
"near_count": 12,
"miss_count": 4,
"clear_type": 1,
"title": {"ja": "テスト1", "en": "Test 1"},
"artist": "pytest",
"time_played": 1704067200000, # 2024-01-01 00:00:00 UTC
"bg": "abcdefg123456hijklmn7890123opqrs",
},
{
"song_id": "test2",
"difficulty": 2,
"modifier": 0,
"rating": 12.0,
"score": 9998123,
"perfect_count": 1234,
"near_count": 1,
"miss_count": 0,
"clear_type": 1,
"title": {"ja": "テスト2", "en": "Test 2"},
"artist": "pytest",
"time_played": 1704067200000,
"bg": "abcdefg123456hijklmn7890123opqrs",
},
],
"recent_rated_scores": [
{
"song_id": "test2",
"difficulty": 2,
"modifier": 0,
"rating": 12.0,
"score": 9998123,
"perfect_count": 1234,
"near_count": 1,
"miss_count": 0,
"clear_type": 1,
"title": {"ja": "テスト2", "en": "Test 2"},
"artist": "pytest",
"time_played": 1704153600000, # 2024-01-02 00:00:00 UTC
"bg": "abcdefg123456hijklmn7890123opqrs",
}
],
},
}
class TestArcaeaOnlineApiParser:
API_RESULT_CONTENT = json.dumps(API_RESULT, ensure_ascii=False)
def test_parse(self):
play_results = ArcaeaOnlineApiParser(self.API_RESULT_CONTENT).parse()
assert all(isinstance(item, PlayResult) for item in play_results)
assert len(play_results) == 2
test1 = next(filter(lambda x: x.song_id == "test1", play_results))
assert test1.rating_class is ArcaeaRatingClass.FUTURE
assert test1.score == 9908123
assert test1.pure == 1234
assert test1.far == 12
assert test1.lost == 4
assert test1.date == datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
assert test1.clear_type is ArcaeaPlayResultClearType.NORMAL_CLEAR
assert test1.modifier is ArcaeaPlayResultModifier.NORMAL
test2 = next(filter(lambda x: x.song_id == "test2", play_results))
assert test2.date == datetime(2024, 1, 2, 0, 0, 0, tzinfo=timezone.utc)

View File

@ -0,0 +1,55 @@
import sqlite3
from datetime import datetime
import pytest
import tests.resources
from arcaea_offline.constants.enums.arcaea import (
ArcaeaPlayResultClearType,
ArcaeaPlayResultModifier,
ArcaeaRatingClass,
)
from arcaea_offline.external.importers.arcaea.st3 import ArcaeaSt3Parser
db = sqlite3.connect(":memory:")
db.executescript(tests.resources.get_resource("st3.sql").read_text(encoding="utf-8"))
class TestArcaeaSt3Parser:
@property
def play_results(self):
return ArcaeaSt3Parser.parse(db)
def test_basic(self):
play_results = self.play_results
assert len(play_results) == 4
test1 = next(filter(lambda x: x.song_id == "test1", play_results))
assert test1.rating_class is ArcaeaRatingClass.FUTURE
assert test1.score == 9441167
assert test1.pure == 895
assert test1.far == 32
assert test1.lost == 22
assert test1.date == datetime.fromtimestamp(1722100000).astimezone()
assert test1.clear_type is ArcaeaPlayResultClearType.TRACK_LOST
assert test1.modifier is ArcaeaPlayResultModifier.HARD
def test_corrupt_handling(self):
play_results = self.play_results
corrupt1 = filter(lambda x: x.song_id == "corrupt1", play_results)
# `rating_class` out of range, so this should be ignored during parsing,
# thus does not present in the result.
assert len(list(corrupt1)) == 0
corrupt2 = next(filter(lambda x: x.song_id == "corrupt2", play_results))
assert corrupt2.clear_type is None
assert corrupt2.modifier is None
date1 = next(filter(lambda x: x.song_id == "date1", play_results))
assert date1.date is None
def test_invalid_input(self):
pytest.raises(TypeError, ArcaeaSt3Parser.parse, "abcdefghijklmn")
pytest.raises(TypeError, ArcaeaSt3Parser.parse, 123456)

View File

@ -0,0 +1,34 @@
import sqlite3
import tests.resources
from arcaea_offline.constants.enums.arcaea import ArcaeaRatingClass
from arcaea_offline.database.models.v5 import ChartInfo
from arcaea_offline.external.importers.chart_info_database import (
ChartInfoDatabaseParser,
)
db = sqlite3.connect(":memory:")
db.executescript(tests.resources.get_resource("cidb.sql").read_text(encoding="utf-8"))
class TestChartInfoDatabaseParser:
def test_parse(self):
items = ChartInfoDatabaseParser.parse(db)
assert all(isinstance(item, ChartInfo) for item in items)
assert len(items) == 3
test1 = next(filter(lambda x: x.song_id == "test1", items))
assert test1.rating_class is ArcaeaRatingClass.PRESENT
assert test1.constant == 90
assert test1.notes == 900
test2 = next(filter(lambda x: x.song_id == "test2", items))
assert test2.rating_class is ArcaeaRatingClass.FUTURE
assert test2.constant == 95
assert test2.notes == 950
test3 = next(filter(lambda x: x.song_id == "test3", items))
assert test3.rating_class is ArcaeaRatingClass.BEYOND
assert test3.constant == 100
assert test3.notes is None

View File

@ -0,0 +1,16 @@
import importlib.resources
import sys
def get_resource(path: str):
"""
A wrapper for `importlib.resources.files()` since it's not available in Python 3.8.
"""
if sys.version_info >= (3, 9, 0):
with importlib.resources.as_file(
importlib.resources.files(__name__).joinpath(path)
) as resource_path:
return resource_path
with importlib.resources.path(__name__, path) as resource_path:
return resource_path

11
tests/resources/cidb.sql Normal file
View File

@ -0,0 +1,11 @@
CREATE TABLE charts_info (
song_id TEXT NOT NULL,
rating_class INTEGER NOT NULL,
constant INTEGER NOT NULL,
notes INTEGER
);
INSERT INTO charts_info (song_id, rating_class, constant, notes) VALUES
("test1", 1, 90, 900),
("test2", 2, 95, 950),
("test3", 3, 100, NULL);

37
tests/resources/st3.sql Normal file
View File

@ -0,0 +1,37 @@
CREATE TABLE scores (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
version INTEGER,
score INTEGER,
shinyPerfectCount INTEGER,
perfectCount INTEGER,
nearCount INTEGER,
missCount INTEGER,
date INTEGER,
songId TEXT,
songDifficulty INTEGER,
modifier INTEGER,
health INTEGER,
ct INTEGER DEFAULT 0
);
CREATE TABLE cleartypes (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
songId TEXT,
songDifficulty INTEGER,
clearType INTEGER,
ct INTEGER DEFAULT 0
);
INSERT INTO scores ("id", "version", "score", "shinyPerfectCount", "perfectCount", "nearCount", "missCount", "date", "songId", "songDifficulty", "modifier", "health", "ct") VALUES
('1', '1', '9441167', '753', '895', '32', '22', '1722100000', 'test1', '2', '2', '0', '0'),
('2', '1', '9752087', '914', '1024', '29', '12', '1722200000', 'test2', '2', '0', '100', '0'),
('3', '1', '9750000', '900', '1000', '20', '10', '1722200000', 'corrupt1', '5', '0', '0', '0'),
('4', '1', '9750000', '900', '1000', '20', '10', '1722200000', 'corrupt2', '2', '9', '0', '0'),
('5', '1', '9750000', '900', '1000', '20', '10', '1', 'date1', '2', '0', '0', '0');
INSERT INTO cleartypes ("id", "songId", "songDifficulty", "clearType", "ct") VALUES
('1', 'test1', '2', '0', '0'),
('2', 'test2', '2', '1', '0'),
('3', 'corrupt1', '5', '0', '0'),
('4', 'corrupt2', '2', '7', '0'),
('5', 'date1', '2', '1', '0');

View File

@ -13,4 +13,4 @@ wheel_build_env = .pkg
deps =
pytest==7.4.3
commands =
pytest {tty:--color=yes} {posargs}
python -m pytest {tty:--color=yes} {posargs}