0.3.32
This commit is contained in:
@@ -20,6 +20,11 @@ const maxAnalyticsBatch = 200
|
||||
// nothing about what anyone was looking at.
|
||||
const maxDwellMs = 30 * 60 * 1000
|
||||
|
||||
// maxPositionMs clamps a pause/resume step's reported position. A title that long does
|
||||
// not exist, so a wilder value says the field was misread rather than that playback was
|
||||
// genuinely there.
|
||||
const maxPositionMs = 24 * 60 * 60 * 1000
|
||||
|
||||
type rowEventPayload struct {
|
||||
RowID string `json:"rowId"`
|
||||
RowKind string `json:"rowKind"`
|
||||
@@ -52,6 +57,9 @@ type journeyEventPayload struct {
|
||||
PlaySessionID string `json:"playSessionId"`
|
||||
Outcome string `json:"outcome"`
|
||||
OccurredAt string `json:"occurredAt"`
|
||||
// PositionMs is where playback was, in the title, on a pause or resume step. Absent
|
||||
// (zero) on every other kind of step.
|
||||
PositionMs int64 `json:"positionMs"`
|
||||
}
|
||||
|
||||
type journeyAnalyticsRequest struct {
|
||||
@@ -63,7 +71,7 @@ var journeyActions = allowedAnalyticsValues(
|
||||
"journey_start", "home_open", "journey_end", "screen_view", "open", "close", "select",
|
||||
"submit", "request", "start", "stop", "complete", "abandon", "change",
|
||||
"toggle", "follow", "unfollow", "favourite", "unfavourite", "mark_played",
|
||||
"mark_unplayed", "retry", "dismiss", "switch",
|
||||
"mark_unplayed", "retry", "dismiss", "switch", "pause", "resume",
|
||||
)
|
||||
var journeyOutcomes = allowedAnalyticsValues("", "success", "failure", "cancelled", "completed", "abandoned")
|
||||
|
||||
@@ -122,7 +130,18 @@ func toJourneyEvent(payload journeyEventPayload, userID string, now time.Time) (
|
||||
Screen: payload.Screen, Feature: payload.Feature, Source: payload.Source,
|
||||
Target: payload.Target, ItemID: payload.ItemID,
|
||||
ItemName: strings.TrimSpace(payload.ItemName), ItemType: payload.ItemType,
|
||||
PlaySessionID: payload.PlaySessionID, Outcome: payload.Outcome}, true
|
||||
PlaySessionID: payload.PlaySessionID, Outcome: payload.Outcome,
|
||||
PositionMs: clampPositionMs(payload.PositionMs)}, true
|
||||
}
|
||||
|
||||
func clampPositionMs(value int64) int64 {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
if value > maxPositionMs {
|
||||
return maxPositionMs
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func safeAnalyticsValue(value string, max int) bool {
|
||||
|
||||
@@ -90,6 +90,48 @@ func TestJourneyEventRejectsAnUnreadablePlaySession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Pause and resume are playback steps like start and complete, and carry where playback
|
||||
// was rather than an outcome — the console derives the pause's length from the gap
|
||||
// between a pause row's timestamp and its matching resume's.
|
||||
func TestJourneyEventAcceptsPauseAndResume(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
for _, action := range []string{"pause", "resume"} {
|
||||
payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "playback",
|
||||
Action: action, Screen: "player", Feature: "playback", Source: "continue_watching",
|
||||
Target: "player", ItemID: "42", ItemName: "Whale Rider", ItemType: "Movie",
|
||||
PlaySessionID: "b3d1f0a4-9c22-4f61-8a10-77d0e2c9aa51", PositionMs: 925000}
|
||||
event, ok := toJourneyEvent(payload, "u1", now)
|
||||
if !ok {
|
||||
t.Fatalf("action %q was rejected", action)
|
||||
}
|
||||
if event.PositionMs != 925000 {
|
||||
t.Fatalf("action %q: position was not retained: %+v", action, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A wild position is clamped rather than dropped: the step it describes is worth more
|
||||
// than one field on it, the same trade dwell time makes on the row events.
|
||||
func TestJourneyEventClampsAWildPosition(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
base := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "playback",
|
||||
Action: "pause", Screen: "player", Feature: "playback", ItemID: "42"}
|
||||
|
||||
negative := base
|
||||
negative.PositionMs = -500
|
||||
event, ok := toJourneyEvent(negative, "u1", now)
|
||||
if !ok || event.PositionMs != 0 {
|
||||
t.Fatalf("negative position was not clamped to zero: %+v", event)
|
||||
}
|
||||
|
||||
wild := base
|
||||
wild.PositionMs = maxPositionMs + 1
|
||||
event, ok = toJourneyEvent(wild, "u1", now)
|
||||
if !ok || event.PositionMs != maxPositionMs {
|
||||
t.Fatalf("oversized position was not clamped: %+v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJourneyEventRetainsTheItemName(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "content",
|
||||
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
featureTrickplay = "trickplay"
|
||||
featureSkipIntro = "skip_intro"
|
||||
featureEndCredits = "end_credits"
|
||||
featureNextEpisodeAiring = "next_episode_airing"
|
||||
featureSeasonalThemes = "seasonal_themes"
|
||||
featureSeasonalDecorations = "seasonal_decorations"
|
||||
featureGenreBrowser = "genre_browser"
|
||||
@@ -251,6 +252,13 @@ var featureCatalogue = []featureDefinition{
|
||||
DefaultEnabled: true, MinimumProtocol: 1, Capability: "end_credits_v1",
|
||||
Recovery: "Takes effect the next time playback starts; the credits simply play out full size.",
|
||||
},
|
||||
{
|
||||
Key: featureNextEpisodeAiring, Name: "Next episode airing notice", Area: "Playback",
|
||||
Description: "Show a quiet notice ahead of the Next Up banner naming when a " +
|
||||
"continuing show's next episode airs, from Sonarr's schedule.",
|
||||
DefaultEnabled: true, MinimumProtocol: 1, Capability: "next_episode_airing_v1",
|
||||
Recovery: "Takes effect the next time playback starts; the notice simply stops appearing.",
|
||||
},
|
||||
{
|
||||
// The only switch there is for seasonal themes, and it is deliberately the
|
||||
// operator's rather than the viewer's: a per-person opt-out is a thing somebody
|
||||
|
||||
@@ -62,6 +62,15 @@ type playbackResponse struct {
|
||||
// separate features with separate switches, and a house that has turned the skip button
|
||||
// off has not asked to lose the credits pane with it.
|
||||
EndCreditsAvailable bool `json:"endCreditsAvailable"`
|
||||
// Whether Sonarr can name when this continuing show's next episode airs. Rides here for
|
||||
// the same reason the other availability flags do: the player asks once at the start of
|
||||
// playback and the notice itself takes no further request. Absent (all fields blank)
|
||||
// means either the show isn't Sonarr-tracked, isn't continuing, or has no scheduled
|
||||
// airing to report — the client shows nothing in every one of those cases.
|
||||
NextAiringAvailable bool `json:"nextAiringAvailable"`
|
||||
NextAiringLabel string `json:"nextAiringLabel,omitempty"`
|
||||
NextAiringDayLabel string `json:"nextAiringDayLabel,omitempty"`
|
||||
NextAiringEpisodeCode string `json:"nextAiringEpisodeCode,omitempty"`
|
||||
}
|
||||
|
||||
type playableSubtitle struct {
|
||||
@@ -114,6 +123,10 @@ type nextEpisodeResponse struct {
|
||||
TrickplayAvailable bool `json:"trickplayAvailable"`
|
||||
SkipIntroAvailable bool `json:"skipIntroAvailable"`
|
||||
EndCreditsAvailable bool `json:"endCreditsAvailable"`
|
||||
NextAiringAvailable bool `json:"nextAiringAvailable"`
|
||||
NextAiringLabel string `json:"nextAiringLabel,omitempty"`
|
||||
NextAiringDayLabel string `json:"nextAiringDayLabel,omitempty"`
|
||||
NextAiringEpisodeCode string `json:"nextAiringEpisodeCode,omitempty"`
|
||||
}
|
||||
|
||||
// handlePlayback resolves what to actually play.
|
||||
@@ -235,6 +248,8 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
|
||||
"negotiation_duration", time.Since(negotiationStarted).Round(time.Millisecond),
|
||||
)
|
||||
|
||||
nextAiringAvailable, nextAiringLabel, nextAiringDayLabel, nextAiringCode :=
|
||||
s.nextAiringFieldsFor(ctx, target)
|
||||
writeJSON(w, http.StatusOK, playbackResponse{
|
||||
ItemID: target.ID,
|
||||
Title: title,
|
||||
@@ -257,6 +272,10 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
|
||||
TrickplayAvailable: s.trickplayEnabled(ctx),
|
||||
SkipIntroAvailable: s.skipIntroEnabled(ctx),
|
||||
EndCreditsAvailable: s.endCreditsEnabled(ctx),
|
||||
NextAiringAvailable: nextAiringAvailable,
|
||||
NextAiringLabel: nextAiringLabel,
|
||||
NextAiringDayLabel: nextAiringDayLabel,
|
||||
NextAiringEpisodeCode: nextAiringCode,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -336,6 +355,22 @@ func (s *Server) firstPlayableEpisode(
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
// nextAiringFieldsFor answers the current episode's own show's next airing, never the
|
||||
// episode being played itself. It only ever asks Sonarr about a series that is actually
|
||||
// known — a hinted playback response that skipped the Emby lookup and so carries no
|
||||
// SeriesName simply gets nothing, the same silent degradation every other availability
|
||||
// flag here takes.
|
||||
func (s *Server) nextAiringFieldsFor(ctx context.Context, item emby.Summary) (available bool, label, dayLabel, code string) {
|
||||
if !strings.EqualFold(item.Type, "Episode") {
|
||||
return false, "", "", ""
|
||||
}
|
||||
info, ok := s.nextAiringInfoFor(ctx, item.SeriesName)
|
||||
if !ok {
|
||||
return false, "", "", ""
|
||||
}
|
||||
return true, info.Label, info.DayLabel, info.EpisodeCode
|
||||
}
|
||||
|
||||
func episodeCode(item emby.Summary) string {
|
||||
if !strings.EqualFold(item.Type, "Episode") || item.ParentIndexNumber < 0 || item.IndexNumber <= 0 {
|
||||
return ""
|
||||
@@ -435,20 +470,26 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
|
||||
|
||||
// The metadata-only shape. Everything below this point is a PlaybackInfo negotiation,
|
||||
// and a client that said it does not want one yet must not be given one anyway.
|
||||
nextAiringAvailable, nextAiringLabel, nextAiringDayLabel, nextAiringCode :=
|
||||
s.nextAiringFieldsFor(ctx, next)
|
||||
if !nextEpisodeWantsStream(r) {
|
||||
s.playbackTitles.remember(next.ID, title)
|
||||
s.loggerFor(ctx).Debug("next episode identified",
|
||||
"title", title, "item", next.ID, "after_item", itemID)
|
||||
writeJSON(w, http.StatusOK, nextEpisodeResponse{
|
||||
Item: raw,
|
||||
Title: title,
|
||||
StreamReady: false,
|
||||
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
|
||||
Subtitles: []playableSubtitle{},
|
||||
SubtitlesEnabled: true,
|
||||
TrickplayAvailable: s.trickplayEnabled(ctx),
|
||||
SkipIntroAvailable: s.skipIntroEnabled(ctx),
|
||||
EndCreditsAvailable: s.endCreditsEnabled(ctx),
|
||||
Item: raw,
|
||||
Title: title,
|
||||
StreamReady: false,
|
||||
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
|
||||
Subtitles: []playableSubtitle{},
|
||||
SubtitlesEnabled: true,
|
||||
TrickplayAvailable: s.trickplayEnabled(ctx),
|
||||
SkipIntroAvailable: s.skipIntroEnabled(ctx),
|
||||
EndCreditsAvailable: s.endCreditsEnabled(ctx),
|
||||
NextAiringAvailable: nextAiringAvailable,
|
||||
NextAiringLabel: nextAiringLabel,
|
||||
NextAiringDayLabel: nextAiringDayLabel,
|
||||
NextAiringEpisodeCode: nextAiringCode,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -491,6 +532,10 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
|
||||
TrickplayAvailable: s.trickplayEnabled(ctx),
|
||||
SkipIntroAvailable: s.skipIntroEnabled(ctx),
|
||||
EndCreditsAvailable: s.endCreditsEnabled(ctx),
|
||||
NextAiringAvailable: nextAiringAvailable,
|
||||
NextAiringLabel: nextAiringLabel,
|
||||
NextAiringDayLabel: nextAiringDayLabel,
|
||||
NextAiringEpisodeCode: nextAiringCode,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -582,6 +582,153 @@ func scheduleAirLabel(airTime, now time.Time, location *time.Location) string {
|
||||
}
|
||||
}
|
||||
|
||||
// nextAiringInfo is what the in-player notice needs about a continuing show's next
|
||||
// episode: the household's own wording for when it airs, and the episode code when it can
|
||||
// be resolved cheaply.
|
||||
type nextAiringInfo struct {
|
||||
Label string
|
||||
DayLabel string
|
||||
EpisodeCode string
|
||||
}
|
||||
|
||||
// nextEpisodeAiringEnabled gates the whole feature: Sonarr must be configured and the
|
||||
// operator must not have switched it off.
|
||||
func (s *Server) nextEpisodeAiringEnabled(ctx context.Context) bool {
|
||||
return s.sonarrEnabled(ctx) && s.featureEnabled(ctx, featureNextEpisodeAiring)
|
||||
}
|
||||
|
||||
// nextAiringInfoFor resolves a currently-playing episode's series against Sonarr's own
|
||||
// catalogue (already cached by sonarrSeriesCatalogue) and answers only when the show is
|
||||
// still being made and Sonarr has a scheduled airing in the future. Matched by title alone
|
||||
// — a playing episode's summary carries no series year, and title collisions are rare
|
||||
// enough that My Shows accepts the same trade when a follow was saved with no year either.
|
||||
func (s *Server) nextAiringInfoFor(ctx context.Context, seriesName string) (nextAiringInfo, bool) {
|
||||
if !s.nextEpisodeAiringEnabled(ctx) {
|
||||
return nextAiringInfo{}, false
|
||||
}
|
||||
catalogue, err := s.sonarrSeriesCatalogue(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Debug("next airing lookup unavailable", "error", err)
|
||||
return nextAiringInfo{}, false
|
||||
}
|
||||
location := s.cfg.SonarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
now := time.Now().In(location)
|
||||
matched, info, ok := resolveNextAiring(catalogue, seriesName, now, location)
|
||||
if !ok {
|
||||
return nextAiringInfo{}, false
|
||||
}
|
||||
info.EpisodeCode = s.nextAiringEpisodeCode(ctx, matched.ID, *matched.NextAiring)
|
||||
return info, true
|
||||
}
|
||||
|
||||
// resolveNextAiring is the pure half of nextAiringInfoFor: given Sonarr's catalogue and the
|
||||
// series being played, decide whether there is anything to say and, when there is, the
|
||||
// matched series so the caller can look up its episode code. Kept apart from the Server
|
||||
// method so the matching, lifecycle and future-airing rules can be pinned without a live
|
||||
// cache or Sonarr client.
|
||||
func resolveNextAiring(
|
||||
catalogue []sonarr.Series, seriesName string, now time.Time, location *time.Location,
|
||||
) (*sonarr.Series, nextAiringInfo, bool) {
|
||||
seriesName = strings.TrimSpace(seriesName)
|
||||
if seriesName == "" {
|
||||
return nil, nextAiringInfo{}, false
|
||||
}
|
||||
key := normalizedShowTitle(seriesName)
|
||||
var matched *sonarr.Series
|
||||
for i := range catalogue {
|
||||
if normalizedShowTitle(catalogue[i].Title) != key {
|
||||
continue
|
||||
}
|
||||
matched = &catalogue[i]
|
||||
break
|
||||
}
|
||||
if matched == nil {
|
||||
return nil, nextAiringInfo{}, false
|
||||
}
|
||||
if seriesLifecycleTag(matched.Status).Status != "continuing" {
|
||||
return nil, nextAiringInfo{}, false
|
||||
}
|
||||
if matched.NextAiring == nil || !matched.NextAiring.After(now) {
|
||||
return nil, nextAiringInfo{}, false
|
||||
}
|
||||
airTime := matched.NextAiring.In(location)
|
||||
return matched, nextAiringInfo{
|
||||
Label: scheduleAirLabel(airTime, now, location),
|
||||
DayLabel: scheduleAirDayLabel(airTime, now, location),
|
||||
}, true
|
||||
}
|
||||
|
||||
// nextAiringEpisodeCode is a best-effort lookup against the same 5-day calendar window the
|
||||
// schedule row already fetches and caches. A next airing beyond that window, or one Sonarr
|
||||
// has not yet attached to a specific episode, omits the code rather than guessing at it.
|
||||
func (s *Server) nextAiringEpisodeCode(ctx context.Context, seriesID int, nextAiring time.Time) string {
|
||||
episodes, err := s.sonarrUpcomingEpisodes(ctx)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, episode := range episodes {
|
||||
candidateSeriesID := episode.SeriesID
|
||||
if episode.Series.ID > 0 {
|
||||
candidateSeriesID = episode.Series.ID
|
||||
}
|
||||
if candidateSeriesID != seriesID || episode.AirDateUTC == nil {
|
||||
continue
|
||||
}
|
||||
if episode.AirDateUTC.Equal(nextAiring) {
|
||||
return fmt.Sprintf("S%02dE%02d", episode.SeasonNumber, episode.EpisodeNumber)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sonarrUpcomingEpisodes shares the schedule row's own cached calendar window, so this
|
||||
// feature costs no extra request to Sonarr beyond what the launcher's schedule row already
|
||||
// pays for once a day.
|
||||
func (s *Server) sonarrUpcomingEpisodes(ctx context.Context) ([]sonarr.Episode, error) {
|
||||
location := s.cfg.SonarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
now := time.Now().In(location)
|
||||
dayStart := localDayStart(now, location)
|
||||
key := sonarrCalendarCachePrefix + "episodes:" + dayStart.Format("2006-01-02")
|
||||
if episodes := s.cachedSonarrEpisodes(ctx, key); episodes != nil {
|
||||
return episodes, nil
|
||||
}
|
||||
|
||||
s.sonarrMu.Lock()
|
||||
defer s.sonarrMu.Unlock()
|
||||
if episodes := s.cachedSonarrEpisodes(ctx, key); episodes != nil {
|
||||
return episodes, nil
|
||||
}
|
||||
|
||||
episodes, err := s.sonarr.Calendar(ctx, dayStart, dayStart.AddDate(0, 0, sonarrScheduleDays))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body, marshalErr := json.Marshal(episodes); marshalErr == nil {
|
||||
if cacheErr := s.cache.Set(ctx, key, body, s.cfg.SonarrTTL); cacheErr != nil {
|
||||
s.loggerFor(ctx).Warn("sonarr upcoming episodes cache write failed", "error", cacheErr)
|
||||
}
|
||||
}
|
||||
return episodes, nil
|
||||
}
|
||||
|
||||
func (s *Server) cachedSonarrEpisodes(ctx context.Context, key string) []sonarr.Episode {
|
||||
raw, err := s.cache.Get(ctx, key)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var episodes []sonarr.Episode
|
||||
if json.Unmarshal(raw, &episodes) != nil {
|
||||
return nil
|
||||
}
|
||||
return episodes
|
||||
}
|
||||
|
||||
func localDayStart(value time.Time, location *time.Location) time.Time {
|
||||
value = value.In(location)
|
||||
return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, location)
|
||||
|
||||
@@ -250,3 +250,57 @@ func TestBuildSonarrRowCoversFiveDaysAndUsesRelativeAirLabels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNextAiringOnlyAnswersForContinuingShowsWithAFutureAiring(t *testing.T) {
|
||||
location := time.UTC
|
||||
now := time.Date(2026, 8, 26, 12, 0, 0, 0, location)
|
||||
tomorrow := now.AddDate(0, 0, 1)
|
||||
yesterday := now.AddDate(0, 0, -1)
|
||||
|
||||
catalogue := []sonarr.Series{
|
||||
{Title: "Continuing Show", Status: "continuing", NextAiring: &tomorrow},
|
||||
{Title: "Ended Show", Status: "ended", NextAiring: &tomorrow},
|
||||
{Title: "Cancelled Show", Status: "cancelled", NextAiring: &tomorrow},
|
||||
{Title: "No Schedule Show", Status: "continuing", NextAiring: nil},
|
||||
{Title: "Already Aired Show", Status: "continuing", NextAiring: &yesterday},
|
||||
}
|
||||
|
||||
if _, info, ok := resolveNextAiring(catalogue, "Continuing Show", now, location); !ok {
|
||||
t.Fatal("expected a continuing show with a future airing to resolve")
|
||||
} else if info.DayLabel != "Tomorrow" {
|
||||
t.Fatalf("day label = %q, want Tomorrow", info.DayLabel)
|
||||
}
|
||||
|
||||
cases := []string{
|
||||
"Ended Show", "Cancelled Show", "No Schedule Show", "Already Aired Show", "Unknown Show",
|
||||
}
|
||||
for _, name := range cases {
|
||||
if _, _, ok := resolveNextAiring(catalogue, name, now, location); ok {
|
||||
t.Fatalf("%q should not have produced a next-airing answer", name)
|
||||
}
|
||||
}
|
||||
|
||||
if _, _, ok := resolveNextAiring(catalogue, " ", now, location); ok {
|
||||
t.Fatal("a blank series name should never resolve")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNextAiringWordingMatchesTheScheduleRow(t *testing.T) {
|
||||
location := time.FixedZone("NZST", 12*60*60)
|
||||
now := time.Date(2026, 7, 27, 8, 0, 0, 0, location)
|
||||
air := now.Add(8 * time.Hour)
|
||||
catalogue := []sonarr.Series{
|
||||
{Title: "Northbound", Status: "continuing", NextAiring: &air},
|
||||
}
|
||||
|
||||
_, info, ok := resolveNextAiring(catalogue, "Northbound", now, location)
|
||||
if !ok {
|
||||
t.Fatal("expected a resolved answer")
|
||||
}
|
||||
wantLabel := scheduleAirLabel(air, now, location)
|
||||
wantDayLabel := scheduleAirDayLabel(air, now, location)
|
||||
if info.Label != wantLabel || info.DayLabel != wantDayLabel {
|
||||
t.Fatalf("wording drifted from the schedule row: got %+v, want label=%q day=%q",
|
||||
info, wantLabel, wantDayLabel)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user