mirror of
https://github.com/283375/arcaea-offline.git
synced 2025-07-01 04:06:27 +00:00
Compare commits
6 Commits
0.3.0-refa
...
2a2a063a3c
Author | SHA1 | Date | |
---|---|---|---|
2a2a063a3c
|
|||
6e2bcbd0ea
|
|||
62c80c9955
|
|||
8e79ffedce
|
|||
677ab6c31e
|
|||
ab88b6903c
|
40
.github/workflows/main.yml
vendored
Normal file
40
.github/workflows/main.yml
vendored
Normal file
@ -0,0 +1,40 @@
|
||||
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']
|
||||
|
||||
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: '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'
|
23
.github/workflows/test.yml
vendored
23
.github/workflows/test.yml
vendored
@ -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'
|
@ -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.4.4
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 5.12.0
|
||||
hooks:
|
||||
- id: isort
|
||||
- id: ruff
|
||||
args: ["--fix"]
|
||||
- id: ruff-format
|
||||
|
@ -18,24 +18,34 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["ruff~=0.4", "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
|
||||
]
|
||||
|
@ -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.4
|
||||
pre-commit~=3.3
|
||||
pytest~=7.4
|
||||
tox~=4.11
|
||||
|
@ -1,2 +1,3 @@
|
||||
from . import world
|
||||
from .play_result import PlayResultCalculators
|
||||
|
||||
__all__ = ["PlayResultCalculators"]
|
||||
|
@ -9,3 +9,17 @@ from .partners import (
|
||||
MayaPartnerBonus,
|
||||
MithraTerceraPartnerBonus,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AmaneBelowExPartnerBonus",
|
||||
"AwakenedEtoPartnerBonus",
|
||||
"AwakenedIlithPartnerBonus",
|
||||
"AwakenedLunaPartnerBonus",
|
||||
"LegacyMapStepBooster",
|
||||
"MayaPartnerBonus",
|
||||
"MemoriesStepBooster",
|
||||
"MithraTerceraPartnerBonus",
|
||||
"PartnerBonus",
|
||||
"WorldMainMapCalculators",
|
||||
"WorldPlayResult",
|
||||
]
|
||||
|
@ -1 +1,3 @@
|
||||
from .db import Database
|
||||
|
||||
__all__ = ["Database"]
|
||||
|
@ -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",
|
||||
]
|
||||
|
@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
38
src/arcaea_offline/database/models/v5/__init__.py
Normal file
38
src/arcaea_offline/database/models/v5/__init__.py
Normal 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",
|
||||
]
|
275
src/arcaea_offline/database/models/v5/arcaea.py
Normal file
275
src/arcaea_offline/database/models/v5/arcaea.py
Normal 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,
|
||||
)
|
62
src/arcaea_offline/database/models/v5/base.py
Normal file
62
src/arcaea_offline/database/models/v5/base.py
Normal 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__()
|
12
src/arcaea_offline/database/models/v5/config.py
Normal file
12
src/arcaea_offline/database/models/v5/config.py
Normal 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()
|
197
src/arcaea_offline/database/models/v5/play_results.py
Normal file
197
src/arcaea_offline/database/models/v5/play_results.py
Normal 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,
|
||||
)
|
@ -1 +1,3 @@
|
||||
from .api_data import AndrealImageGeneratorApiDataConverter
|
||||
|
||||
__all__ = ["AndrealImageGeneratorApiDataConverter"]
|
||||
|
@ -2,3 +2,11 @@ from .online import ArcaeaOnlineParser
|
||||
from .packlist import PacklistParser
|
||||
from .songlist import SonglistDifficultiesParser, SonglistParser
|
||||
from .st3 import St3ScoreParser
|
||||
|
||||
__all__ = [
|
||||
"ArcaeaOnlineParser",
|
||||
"PacklistParser",
|
||||
"SonglistDifficultiesParser",
|
||||
"SonglistParser",
|
||||
"St3ScoreParser",
|
||||
]
|
||||
|
@ -1 +1,3 @@
|
||||
from .arcsong_db import ArcsongDbParser
|
||||
|
||||
__all__ = ["ArcsongDbParser"]
|
||||
|
@ -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:
|
||||
|
@ -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,
|
||||
|
@ -1 +1,3 @@
|
||||
from .parser import ChartInfoDbParser
|
||||
|
||||
__all__ = ["ChartInfoDbParser"]
|
||||
|
@ -1,2 +0,0 @@
|
||||
from . import exporters
|
||||
from .types import ArcaeaOfflineDEFV2_Score, ScoreExport
|
||||
|
@ -1,4 +1,5 @@
|
||||
from ...models import Score
|
||||
from arcaea_offline.database.models.v4 import Score
|
||||
|
||||
from .types import ArcaeaOfflineDEFV2_ScoreItem, ScoreExport
|
||||
|
||||
|
||||
|
31
src/arcaea_offline/external/importers/arcaea/common.py
vendored
Normal file
31
src/arcaea_offline/external/importers/arcaea/common.py
vendored
Normal 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)
|
178
src/arcaea_offline/external/importers/arcaea/lists.py
vendored
Normal file
178
src/arcaea_offline/external/importers/arcaea/lists.py
vendored
Normal 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 PacklistParser(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 SonglistParser(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()
|
@ -1 +1,3 @@
|
||||
from .b30_csv import SmartRteB30CsvConverter
|
||||
|
||||
__all__ = ["SmartRteB30CsvConverter"]
|
||||
|
@ -1,2 +1,7 @@
|
||||
from .play_result import PlayResultFormatter
|
||||
from .rating_class import RatingClassFormatter
|
||||
|
||||
__all__ = [
|
||||
"PlayResultFormatter",
|
||||
"RatingClassFormatter",
|
||||
]
|
||||
|
@ -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
|
||||
|
@ -1,5 +0,0 @@
|
||||
from sqlalchemy import Engine, create_engine, inspect
|
||||
|
||||
|
||||
def create_engine_in_memory():
|
||||
return create_engine("sqlite:///:memory:")
|
@ -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)))
|
||||
|
||||
|
@ -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~"
|
0
tests/db/models/v4/__init__.py
Normal file
0
tests/db/models/v4/__init__.py
Normal file
108
tests/db/models/v4/test_songs.py
Normal file
108
tests/db/models/v4/test_songs.py
Normal 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~"
|
0
tests/db/models/v5/__init__.py
Normal file
0
tests/db/models/v5/__init__.py
Normal file
0
tests/db/models/v5/relationships/__init__.py
Normal file
0
tests/db/models/v5/relationships/__init__.py
Normal file
143
tests/db/models/v5/relationships/test_common.py
Normal file
143
tests/db/models/v5/relationships/test_common.py
Normal 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
|
69
tests/db/models/v5/relationships/test_pack.py
Normal file
69
tests/db/models/v5/relationships/test_pack.py
Normal 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
|
110
tests/db/models/v5/test_chart.py
Normal file
110
tests/db/models/v5/test_chart.py
Normal 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
|
Reference in New Issue
Block a user